تخط وانتقل إلى المحتوى الرئيسي

التحقق من التواقيع

كيفية مصادقة webhooks الواردة من DZBuild. مقارنة HMAC بزمن ثابت + فحص حداثة، مع كود جاهز للنسخ بـ Node.js و PHP و Python و Go.

بقلم: Support

🛑 هام جدًا — لا يستطيع التجار بعدُ التحقق من تواقيع webhooks الخاصة بـ API v1

التسليمات القادمة من POST /v1/webhooks تحمل ترويسة X-DZ-Signature، لكنها ليست محسوبة من السر secret الخاص بكل webhook الذي حصلت عليه عند التسجيل — فذلك السر لا دور له في التوقيع اليوم.

لذا فأي فحص HMAC تكتبه بالاعتماد على سر الـ webhook سيرفض 100% من تسليمات API v1 الحقيقية. تعامل مع X-DZ-Signature كقيمة مبهمة إلى أن يصدر التوقيع لكل webhook على حدة.

ما تفعله بدلًا من ذلك مع تسليمات API v1:

  1. اجعل الرابط غير قابل للتخمين — مقطع مسار عشوائي طويل، أو رمز مشترك في سلسلة الاستعلام تتحقق منه عند الوصول.

  2. اقبل POST عبر HTTPS فقط، وتأكد أن X-DZ-Timestamp خلال 5 دقائق من ساعتك.

  3. أعد قراءة السجل قبل التصرّف. نادِ GET /v1/orders/{id} بمفتاح الـ API الخاص بك وثق بذلك، لا بالجسم المدفوع إليك.

  4. أزل التكرار بالاعتماد على delivery_id الموجود في الجسم.

الشيفرة في بقية هذه الصفحة تخصّ إضافة Webhooks للتاجر (/dashboard/webhooks، خطة Unlimited فما فوق)، فتواقيعها قابلة للتحقق بالسر الخاص بكل نقطة لديك.

الوصفة (إضافة Webhooks للتاجر)

ترسل الإضافة ترويسة مفصولة بفواصل على طريقة Stripe، وتوقّع الجسم الخام — لا تجزئته:

X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256>expected = hex( hmac_sha256( WEBHOOK_SECRET, t + "." + raw_body ) )if (!constant_time_equal(expected, v1)) reject 401
if (abs(now - t) > 300)                 reject 401   # نافذة إعادة ±5 دقائق

ثلاث قواعد للأمان:

  1. استخدم بايتات الجسم الخام. إعادة تسلسل JSON تُغيّر مدخل التوقيع.

  2. قارن بزمن ثابت. == العادي يُسرّب معلومات توقيت تساعد التخمين العنيف.

  3. ارفض الطوابع القديمة (أكثر من 5 دقائق عن ساعة خادمك). شغّل NTP.

مجموعة الترويسات الكاملة التي يصل بها تسليم الإضافة:

Content-Type:    application/json
User-Agent:      DZBuild-Webhooks/1.0
X-DZ-Timestamp:  <unix seconds>
X-DZ-Signature:  t=<unix seconds>,v1=<hex hmac-sha256>
X-DZ-Event:      order.confirmed
X-DZ-Delivery:   <numeric delivery id>
X-DZ-Token:      <your endpoint secret, in plain text>

X-DZ-Token توأم عملي للتوقيع، موجّه لأدوات الـ no-code (n8n وMake وZapier) التي لا تدعم إلا المصادقة بالترويسات: قارنه بالسر المخزَّن لديك بمقارنة ذات زمن ثابت. إنه بيان اعتماد من نوع bearer داخل ترويسة — لا تستعمله إلا عبر HTTPS، وفضّل HMAC حين تكتب شيفرة حقيقية.

الكود

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;// "t=1717112657,v1=abc..." → { t: "1717112657", v1: "abc..." }
function parseSigHeader(raw) {
  const out = {};
  for (const part of String(raw || '').split(',')) {
    const i = part.indexOf('=');
    if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  return out;
}// مهم: التقط الجسم الخام لـ HMAC، منفصلًا عن JSON المحلَّل.
app.post('/webhooks/dzbuild',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const { t: ts, v1: sig } = parseSigHeader(req.get('X-DZ-Signature'));
    if (!ts || !sig) return res.status(401).end();    if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
      return res.status(401).end();   // طابع زمني قديم أو مستقبلي
    }    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)   // الجسم الخام، لا تجزئته
      .digest('hex');    // افحص الطول أولًا — timingSafeEqual يرمي استثناءً عند اختلاف أطوال المخازن.
    if (expected.length !== sig.length ||
        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
      return res.status(401).end();
    }    // تم التحقق — الآن يمكنك التحليل والتصرّف.
    const event = JSON.parse(req.body.toString('utf8'));
    console.log('verified', req.get('X-DZ-Event'), event);
    res.status(200).end();   // ack بأسرع ما يمكن
  });app.listen(3000);

PHP (raw)

<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');     // الجسم الخام
$header = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';$parts = [];
foreach (explode(',', $header) as $piece) {
    $kv = explode('=', trim($piece), 2);
    if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
$ts  = $parts['t']  ?? '';
$sig = $parts['v1'] ?? '';if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);if (!hash_equals($expected, strtolower($sig))) {
    http_response_code(401);
    exit;
}$event = json_decode($body, true);
// عالج $event['event'] و $event['data']
http_response_code(200);

في Laravel استعمل route middleware أو controller يقرأ `$request->getContent()` للجسم الخام. عطّل CSRF على مسار webhook.

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abortapp = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()def parse_sig(raw):
    out = {}
    for part in (raw or '').split(','):
        k, _, v = part.partition('=')
        if v:
            out[k.strip()] = v.strip()
    return [email protected]('/webhooks/dzbuild')
def receive():
    parts = parse_sig(request.headers.get('X-DZ-Signature'))
    ts, sig = parts.get('t'), parts.get('v1')
    if not ts or not sig: abort(401)
    if abs(int(time.time()) - int(ts)) > 300: abort(401)    body = request.get_data()      # بايتات خام — لا تستعمل request.json
    expected = hmac.new(WEBHOOK_SECRET,
                        ts.encode() + b'.' + body,
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.lower()): abort(401)    event = request.get_json()
    print('verified', request.headers.get('X-DZ-Event'), event)
    return '', 200

Go (net/http)

package mainimport (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))func parseSig(h string) (ts, v1 string) {
    for _, part := range strings.Split(h, ",") {
        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(kv) != 2 { continue }
        switch kv[0] {
        case "t":
            ts = kv[1]
        case "v1":
            v1 = kv[1]
        }
    }
    return
}func receive(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil { http.Error(w, "read", 400); return }    ts, sig := parseSig(r.Header.Get("X-DZ-Signature"))
    if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }    tsInt, err := strconv.ParseInt(ts, 10, 64)
    if err != nil { http.Error(w, "ts", 401); return }
    if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(ts + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))    if !hmac.Equal([]byte(expected), []byte(sig)) {
        http.Error(w, "bad sig", 401); return
    }
    w.WriteHeader(http.StatusOK)
}func abs(x int64) int64 { if x < 0 { return -x }; return x }

قيود جهة الاستقبال

تنطبق هذه على تسليمات API v1، وهي الجواب المعتاد على سؤال "نقطتي لا تُنادى أبدًا":

القيد

القيمة

ماذا يحدث إن خالفته

مهلة الاتصال

5 ثوانٍ

تُحسب إخفاق نقل — محاولة واحدة ثم يُهجر

المهلة الإجمالية

10 ثوانٍ

الأمر ذاته: يُهجر ولا يُعاد أبدًا

إعادة التوجيه

غير متبوعة

301/302 إخفاق، و 3xx لا يُعاد أبدًا

التحقق من TLS

صارم

الشهادات الموقّعة ذاتيًا أو المنتهية تفشل بلا إعادة

الطريقة / الجسم

POST عادي، وجسم JSON

وجّه الـ webhook إلى الرابط النهائي (بلا إعادة توجيه من www إلى النطاق الجذر، وبلا قفزة من HTTP إلى HTTPS) وقدّم شهادة موثوقة عموميًا.

أخطاء شائعة

الخطأ

العَرَض

الحل

التحقق من تسليم API v1 بسر الـ webhook لديك

كل تسليم يُرفض بحجة "توقيع خاطئ"

هذا متوقّع — API v1 لا يوقّع بذلك السر. انظر التحذير في أعلى الصفحة

إعادة تسلسل جسم JSON قبل التوقيع

التوقيع لا يطابق أبدًا

استخدم بايتات الجسم الخام — انظر ملاحظات الأطر في التسجيل

تجزئة الجسم قبل HMAC

التوقيع لا يطابق أبدًا

الإضافة توقّع t + "." + raw_body، لا تجزئة الجسم

قراءة t من X-DZ-Timestamp بينما v1 من ترويسة مجرّدة

أخطاء تحليل / توقيع فارغ

X-DZ-Signature مفصولة بفواصل: t=…,v1=…

انحراف ساعة الخادم

رفض "Timestamp out of window"

شغّل NTP، تحقّق من timedatectl status على Linux

المقارنة بـ == بدل زمن ثابت

ثغرة timing-attack دقيقة

استعمل crypto.timingSafeEqual / hmac.compare_digest / hash_equals

استعمال timingSafeEqual بلا فحص طول

يُرمى RangeError بدل إرجاع 401

قارن الأطوال أولًا، كما في مثال Node

تسجيل السر على القرص

السر ينتهي في ملفات السجل

لا تُسجّله؛ استعمل secrets store؛ أعد توليده إن تسرّب

الردّ بـ 200 فورًا والمعالجة لاحقًا

فقدان أحداث عند تحطّم العامل

إما احفظ في طابورك ثم ack، أو نفّذ العمل بشكل متزامن وردّ آخر شيء

Idempotency من جانبك

نفس delivery_id قد يصل أكثر من مرة. وفي API v1 لهذا شكل محدّد:

  • النقطة التي تُرجع 5xx يُعاد إرسال POST إليها كل 60 ثانية، إلى ما لا نهاية. التكرارات من هذا المسار أمر روتيني لا استثنائي — فإزالة التكرار إلزامية، لا احترازية.

  • النقطة التي تتجاوز المهلة لا تحصل على أي إعادة إطلاقًا. يُهجر التسليم بعد محاولة واحدة، فالنقطة البطيئة تفقد الأحداث بدل أن تستقبلها مرتين. أرجِع الإقرار بسرعة ونفّذ العمل بشكل غير متزامن.

أزل التكرار من جانب الاستقبال:

INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- أمسك انتهاك UNIQUE على delivery_id → معالَج بالفعل، أعد 200 على أي حال

هذا النمط يعني: حتى إن أعدنا النداء، تُنفّذ العمل مرة وتردّ بسرعة.

هل أجاب هذا عن سؤالك؟