← كل المقالات

التحقق من توقيع Webhook في PHP وLaravel بأمان

تحقق من توقيع HMAC لأحداث Webhook في PHP وLaravel باستخدام جسم الطلب الخام وhash_equals وفحص الوقت وتدوير الأسرار ومنع إعادة الاستخدام.

يبني بكري عبدالسلام مواقع الويب والتطبيقات والتكاملات ومنتجات ووردبريس، ويوثق مركز بكري التقني القرارات التقنية وراء هذا العمل.

الخلاصة

تحقق من البايتات التي وقعتها الخدمة قبل Parse أو Queue: Raw Body، سر محفوظ على السيرفر، مقارنة ثابتة الزمن، Timestamp محدود، ثم معالجة Idempotent.

نقطة استقبال Webhook متاحة عبر الإنترنت، ولذلك يستطيع أي طرف إرسال طلب إليها. تحمي HTTPS البيانات أثناء النقل، لكنها لا تثبت أن الطلب صدر من مزود الدفع أو الرسائل. التوقيع هو ما يسمح للتطبيق بالتحقق من المصدر وسلامة المحتوى.

لا يوجد Format موحد لكل الخدمات. يجب اتباع توثيق Provider في اسم Header وطريقة تكوين الرسالة والـAlgorithm والـEncoding. المثال هنا يشرح نمط HMAC شائعاً وليس Adapter عاماً.

استخدم جسم الطلب الخام كما وصل

تحسب Signature على Bytes محددة. عند تحويل JSON إلى Array ثم إعادة json_encode قد يتغير ترتيب المفاتيح أو المسافات أو Escaping.

في PHP:

$rawBody = file_get_contents('php://input');

if ($rawBody === false) {
    http_response_code(400);
    exit('Unable to read request body');
}

في Laravel:

$rawBody = $request->getContent();

نفذ Parse بعد نجاح التحقق:

$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);

لا تستخدم json_encode($request->all()) داخل حساب HMAC؛ هذه ليست بالضرورة الرسالة الأصلية.

كوّن الرسالة الموقعة بدقة

بعض الخدمات توقع Body فقط، وبعضها يوقع Timestamp ثم Separator ثم Body:

$signedMessage = $timestamp . '.' . $rawBody;

حرف واحد مختلف ينتج توقيعاً مختلفاً. راجع:

  • هل الفاصل . أم :؟
  • هل توجد Prefix داخل Header؟
  • هل Signature Hex أم Base64؟
  • هل Timestamp بالثواني أم Milliseconds؟
  • هل توجد أكثر من Signature أثناء تدوير السر؟

حلل Header بصورة Strict وارفض القيم المفقودة أو غير الصالحة أو الكبيرة بشكل غير منطقي.

احسب HMAC واستخدم مقارنة آمنة

عند استخدام HMAC-SHA-256 بصيغة Hex:

$expected = hash_hmac(
    'sha256',
    $signedMessage,
    $webhookSecret
);

$valid = hash_equals($expected, $providedSignature);

تستخدم hash_equals() مقارنة مقاومة لتسريب التوقيت. اجعل القيمة التي حسبها السيرفر هي الوسيط الأول، وقارن النصين بنفس Encoding.

إذا كان Provider يستخدم Base64 للـRaw HMAC:

$expected = base64_encode(
    hash_hmac('sha256', $signedMessage, $webhookSecret, true)
);

لا تجرب صيغاً متعددة حتى تنجح إحداها. طبّق الصيغة المحددة في التوثيق.

احفظ السر داخل Secret Store أو Environment آمنة. لا تضعه في JavaScript أو Git أو Error Page أو Analytics أو Logs عادية.

امنع إعادة استخدام الطلب بفحص الوقت

إذا احتوت الرسالة الموقعة على Timestamp فارفض الطلبات القديمة:

$tolerance = 300;

if (abs(time() - $timestamp) > $tolerance) {
    abort(401, 'Webhook timestamp outside tolerance');
}

تحقق أن القيمة Integer وبالوحدة الصحيحة، وحافظ على مزامنة ساعة السيرفر عبر NTP. يجب أن تكون Timestamp نفسها جزءاً من Signed Message؛ وإلا يستطيع المهاجم تغييرها.

نافذة الزمن تقلل Replay لكنها لا تمنع وصول Event نفسها أكثر من مرة بصورة شرعية.

افصل التحقق عن المتحكم

ضع منطق Provider داخل Class صغيرة قابلة للاختبار:

final class WebhookVerifier
{
    public function verify(
        string $rawBody,
        string $signature,
        int $timestamp,
        string $secret
    ): bool {
        if (abs(time() - $timestamp) > 300) {
            return false;
        }

        $message = $timestamp . '.' . $rawBody;
        $expected = hash_hmac('sha256', $message, $secret);

        return hash_equals($expected, $signature);
    }
}

الترتيب الصحيح داخل Controller:

  1. قراءة Raw Body وHeaders.
  2. التحقق من Format وTimestamp.
  3. التحقق من Signature.
  4. Parse للـJSON وفحص Schema.
  5. تسجيل Event ID بصورة Idempotent.
  6. إرسال العمل إلى Queue والرد سريعاً.

أعد رسالة عامة مثل 400 أو 401. تفاصيل سبب فشل التحقق مكانها سجل تشغيلي محدود الوصول.

دوّر الأسرار دون توقف

خلال فترة انتقال قصيرة يمكن قبول Current Secret وPrevious Secret:

$valid = collect([$currentSecret, $previousSecret])
    ->filter()
    ->contains(fn (string $secret) =>
        $verifier->verify($rawBody, $signature, $timestamp, $secret)
    );

احذف السر السابق بعد تأكيد انتقال Provider. إذا وفر Header معرفاً للمفتاح، استخدمه لاختيار السر بدلاً من تجربة تاريخ طويل.

خصص سراً لكل Environment وProvider وTenant إن أمكن. سر Staging لا يجب أن يقبل Production Events.

اختبر عينات أصلية منزوعة البيانات الحساسة

احفظ عينات منزوعة البيانات الحساسة تحتوي Raw Body وTimestamp وHeader والنتيجة. غطِ الاختبارات التالية:

  • توقيع صحيح.
  • تغيير Byte واحد في Body.
  • سر خاطئ.
  • Header ناقص أو غير صالح.
  • اختلاف Hex وBase64.
  • Timestamp منتهية.
  • أكثر من Signature أثناء Rotation.
  • JSON به Unicode وEscaping.

اختبر HTTP Layer في Laravel أيضاً؛ فقد يقرأ Middleware الـBody أو يغير طريقة الوصول إليه.

اربط التحقق بمنع التكرار

التوقيع يثبت المصدر، لكنه لا يضمن إرسالاً واحداً. خزن Event ID خلف Unique Constraint واجعل Side Effects آمنة عند Retry. راجع منع تكرار Webhooks في Laravel ودليل التكاملات الموثوقة.

أخطاء شائعة

أكثر الأخطاء خطورة: التحقق من JSON معاد تكوينها، استخدام مقارنة عادية، تجاهل Timestamp، تطبيق Algorithm عامة، تسجيل السر، أو تنفيذ Business Logic قبل Authentication.

اجعل Verifier حداً أمنياً صغيراً بقواعد Bytes واضحة وTest Vectors موثوقة.

أسئلة شائعة

هل تغني قائمة عناوين IP المسموحة عن التوقيع؟

غالباً لا. قد تتغير عناوين Provider أو تمر عبر بنية مشتركة، ولا تثبت قواعد الشبكة سلامة Payload. استخدمها كطبقة إضافية إذا كانت الخدمة تدعمها.

هل يمكن تحليل JSON قبل التحقق؟

يمكن قراءة نسخة، لكن حساب Signature يجب أن يستخدم Raw Bytes دون تغيير. الترتيب الأسلم هو التحقق أولاً ثم Parse وValidation.

مراجع رسمية

لديك سؤال عن هذا الدليل أو فكرة لتعاون تقني؟ تواصل مع بكري عبر المركز التقني.

نهاية الملاحظة.