التحقق من توقيع الويب هوك ومنع تكرار الأحداث

بقلم فريق تقني ·· برمجة
التحقق من توقيع الويب هوك ومنع تكرار الأحداث

معالج الويب هوك عنوان POST مكشوف، يقبل ما يصله ما دام شكله صحيحاً. HTTPS يشفّر القناة ويمنع العبث بها أثناء النقل، لكنه لا يخبرك من أرسل.

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

وقّع على البايتات لا على الكائن

Stripe يشترط الجسم الخام للطلب في التحقق، وينبّه إلى أن أي تعديل عليه يُفشل العملية. السبب أن التوقيع محسوب على البايتات كما وصلت، لا على بنية JSON.

حلّل الجسم ثم أعد تسلسله، فتتغيّر المسافات وترتيب المفاتيح ودقّة الأرقام. عندها تحسب HMAC على مدخل مختلف وتفشل المطابقة، بينما يبدو كل شيء سليماً في السجل.

الأطر تتدخل هنا بما لم تطلبه. في Express يبتلع express.json() الجسم قبل أن تصل إليه، والحل استخدام express.raw({type: 'application/json'}) على مسار الويب هوك وحده، قبل أي وسيط تحليل عام. في Django اقرأ request.body لا request.POST، ومن يقرأ التدفّق أولاً يصطدم بـ RawPostDataException. في Laravel المطلوب $request->getContent() لا $request->all().

النافذة الزمنية وسرّ نقطة النهاية

ترويسة Stripe-Signature تحمل الطابع الزمني ببادئة t= والتوقيع ببادئة v1=. السلسلة الموقّعة هي الطابع الزمني، ثم نقطة، ثم الجسم الخام.

قد تحمل الترويسة أكثر من توقيع v1 أثناء تدوير السرّ: السرّ السابق يبقى فعّالاً حتى 24 ساعة، وStripe يولّد توقيعاً لكل سرّ فعّال. اقبل الطلب إذا طابق أيّ توقيع منها، وتجاهل ما ليس v1 منعاً لهجمات التخفيض.

النافذة الافتراضية في مكتبات Stripe 5 دقائق. لا تجعلها صفراً؛ يحذّر Stripe من ذلك لأنه يلغي فحص الحداثة كلياً ويفتح الباب لإعادة تشغيل طلب قديم ملتقَط.

المفتاح المستخدم هو سرّ نقطة النهاية الذي يبدأ بـ whsec_، لا مفتاح API الذي يبدأ بـ sk_. ولكل نقطة نهاية سرّها.

import hmac, hashlib, time

TOLERANCE = 300

def verify_stripe(raw_body: bytes, header: str, secret: str) -> bool:
    ts, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            ts = value
        elif key == "v1":
            signatures.append(value)
    if not ts or not ts.isdecimal() or not signatures:
        return False
    if abs(time.time() - int(ts)) > TOLERANCE:
        return False
    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

لاحظ فحص isdecimal قبل التحويل: ترويسة فيها t=abc ترمي ValueError فتُسقط الطلب في خطأ 500 بدل رفضه نظيفاً. ولا تستبدلها بـisdigit، فهي تقبل محارف رقمية يرفضها int.

اختبار الدالة سهل: جسم ثابت وتوقيع محسوب مسبقاً، وحالات للتوقيع الصحيح والمشوّه والطابع الزمني المنتهي. راجع شرح pytest إن لم تكن معتاداً على كتابة اختبارات بايثون.

ضبط الساعة عبر NTP توصية صريحة من Stripe. انحرافها على خادم افتراضي يُفشل فحص النافذة، ويظهر في السجل كأنه خطأ توقيع.

الترويسة والترميز يختلفان بين المزوّدات

GitHub يستخدم X-Hub-Signature-256 بقيمة ببادئة sha256=، ويبقي X-Hub-Signature بخوارزمية SHA-1 لأغراض التوافق القديم فقط.

Slack يبني سلسلة الأساس بصيغة v0:{timestamp}:{body}، ويرسل النتيجة في X-Slack-Signature ببادئة v0= ضمن نافذة 5 دقائق.

Shopify يختلف في نقطة تكسر التطبيقات كثيراً: HMAC-SHA256 بترميز Base64 لا hex، في ترويسة X-Shopify-Hmac-SHA256، والمفتاح هو client secret.

Stripe وGitHub وSlack على hex، وShopify على Base64. خلط الترميزين سبب شائع لفشل التحقق دون رسالة مفيدة.

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

قارن بزمن ثابت، واحذر مصيدة Node

قارن بدالة ثابتة الزمن: hmac.compare_digest في بايثون، crypto.timingSafeEqual في Node، hash_equals في PHP. GitHub يمنع صراحة استخدام == المجرّد.

في Node فخ إضافي: crypto.timingSafeEqual يرمي RangeError برمز ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH عندما تختلف أطوال العازلتين. ترويسة مشوّهة يرسلها مهاجم تتحوّل من رفض نظيف إلى خطأ 500 في خدمتك. قارن الطول أولاً، وأرجِع false قبل استدعاء الدالة.

منع التكرار يبدأ من معرّف الحدث

Stripe يقول إن نقاط النهاية قد تستقبل الحدث نفسه أكثر من مرة، وحلّه الرسمي تسجيل معرّفات الأحداث المعالَجة وتخطّي المسجَّل منها. Shopify يقول الشيء ذاته ويوفّر X-Shopify-Webhook-Id، ومعرّف GitHub هو X-GitHub-Delivery.

عند GitHub مقايضة تستحق الانتباه: إعادة الإرسال اليدوية تحمل المعرّف نفسه، فحظر المكرر يمنعك من إعادة معالجة حدث فشل عمداً. أبقِ مساراً إدارياً يتجاوز الحظر.

ما يحمل هذا فعلياً قيد فريد مركّب على المزوّد ومعرّف الحدث معاً، وعبارة واحدة ذرّية تكتبه. المزوّد جزء من القيد لأن معرّفات المزوّدات لا تتبع نمطاً واحداً، ومعالج يخدم أكثر من واحد معرّض لتصادم عابر بينها. SELECT ثم INSERT يمرّ منه تسليمان متزامنان معاً، والمجموعة في الذاكرة تسقط عند تعدّد النسخ وعند كل إعادة تشغيل.

CREATE TABLE webhook_events (
  provider    text        NOT NULL,
  event_id    text        NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);

بلا هذا القيد لا يجد ON CONFLICT DO NOTHING ما يتعارض معه، فيتحوّل إلى إدراج عادي يمرّر كل مكرر بصمت.

row = db.execute(
    "INSERT INTO webhook_events (provider, event_id) VALUES (%s, %s) "
    "ON CONFLICT DO NOTHING RETURNING event_id",
    ("stripe", event["id"]),
).fetchone()

if row is None:
    return "", 200

enqueue(event)
return "", 200

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

ترويسة Idempotency-Key عند Stripe لا علاقة لها بهذا؛ هي للطلبات التي ترسلها أنت إلى Stripe، لا لمنع تكرار الوارد.

المهلة تحكم تصميم المعالج

المزوّدالمهلةإعادة المحاولة عند الفشل
Slack3 ثوانٍفوراً، ثم بعد دقيقة، ثم بعد 5 دقائق
Shopify5 ثوانٍحتى 8 مرات خلال 4 ساعات، ثم يُحذف الاشتراك
GitHub10 ثوانٍلا إعادة محاولة تلقائية — إعادة الإرسال يدوية
Stripeلم يُعلن رقمحتى 3 أيام بتراجع أسي في الوضع المباشر

Stripe يطلب إرجاع رمز 2xx بسرعة، قبل أي منطق معقّد قد يتسبب في تجاوز المهلة. Slack يعطّل اشتراك الأحداث مؤقتاً إذا تجاوزت نسبة الفشل 95% من محاولات التسليم خلال 60 دقيقة — للتطبيقات التي تستقبل ألف حدث في الساعة فأكثر. بطء معالجك قد يقطع عنك الأحداث كلها.

الفشل الصامت أسوأ من البطء: أن ترد 200 ثم ينهار المعالج قبل إتمام العمل. المزوّد اعتبر الحدث مُسلَّماً ولن يعيد المحاولة، فيضيع نهائياً. لذلك يجب أن يقع الالتزام الدائم — الإدراج في الجدول أو الطابور — قبل إرسال 200، لا بعده.

ترتيب المعالج في ست خطوات

  1. التقط البايتات الخام قبل أي وسيط تحليل، وعطّل محلّل JSON على هذا المسار وحده.
  2. تحقّق من التوقيع بمقارنة ثابتة الزمن، ومن الطابع الزمني ضمن النافذة، وارفض بـ 400 عند الفشل.
  3. أدرج معرّف الحدث بعبارة INSERT ... ON CONFLICT DO NOTHING RETURNING واحدة.
  4. إن لم يُرجع الإدراج صفاً فالحدث مكرر: أرجِع 2xx وتوقّف دون أي أثر جانبي.
  5. ادفع العمل الفعلي إلى طابور أو جدول مهام، ولا تنفّذه داخل الطلب.
  6. ردّ 2xx بعد أن يصبح الالتزام دائماً في قاعدة البيانات.

اقرأ أيضاً