شعار أوسكار أوسكار كل المقالات

سياسة أمان المحتوى مع أطراف ثالثة: اسمح بلا أن تفتح كل شيء

سياسة أمان المحتوى (Content-Security-Policy، أو CSP اختصاراً) سهلة حين يكون موقعك مغلقاً على نفسه. تكتب default-src 'self' وتنتهي. الأمر يصبح صعباً حين يحمّل موقعك سكربتات لا تملكها: مصادقة، وقاعدة بيانات في الزمن الحقيقي، وخطوط، وأيقونات، وإعلانات. كل واحد منها يريد ثقبه في الجدار، وكل ثقب زائد يقلّل قيمة الجدار كلّه.

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

ما تمنعه CSP فعلاً — وما لا تمنعه

CSP تُخبر المتصفّح: «من هذه المصادر فقط اسمح بتحميل هذا النوع من الموارد». فائدتها الأساسية أنها تحوّل ثغرة حقن سكربت (XSS) من كارثة إلى محاولة فاشلة: المهاجم قد ينجح في إدخال وسم script في صفحتك، لكن المتصفّح يرفض تنفيذه لأن مصدره غير مسموح.

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

نقطة البداية: default-src 'self'

ابدأ بأضيق ما يمكن، ثم افتح ما تحتاجه بالضبط:

default-src 'self'

هذه القيمة الاحتياطية لكل نوع لم تُذكر صريحاً. معناها: لا شيء من خارج نطاقك. ستكسر موقعك فوراً — وهذا مقصود. افتح وحدة التحكّم في المتصفّح، واقرأ كل رسالة رفض، وافتح توجيهةً واحدة لمصدر واحد في كل مرّة. الطريقة العكسية (سياسة فضفاضة تُشدّد لاحقاً) لا تنجح أبداً لأن لا شيء يكسر فلا تعرف ما يجب إغلاقه.

التوجيهات كما نستخدمها فعلاً

script-src أخطرها وأهمّها. نسمح فيها بنطاقنا، ونطاقات خدمة المصادقة وقاعدة البيانات، ونطاق سكربت الإعلانات. وفيها إقرارَان يستحقّان الشرح:

  • 'unsafe-inline' — نعم، هذا تنازل حقيقي يُضعف الحماية من حقن السكربتات، وسببه أن الصفحة تحتوي سكربتات مضمّنة وسمات onclick في القالب. البديل الصحيح استخدام nonce أو hash لكل سكربت مضمّن. من يستضيف على منصّة ثابتة بلا خادم يولّد قيمة عشوائية لكل طلب يجد هذا مكلفاً، فيكون القرار واعياً موثّقاً لا سهواً — والفرق بينهما أن الواعي يُخطَّط لإزالته.
  • 'wasm-unsafe-eval' — تسمح بتجميع WebAssembly دون فتح eval للنصوص. بعض حِزَم التطوير تحتاجها، وهي أضيق كثيراً من 'unsafe-eval' الكامل. اختر الأضيق دائماً حين يتوفّر الخيار.

style-src نطاقنا ونطاق الخطوط، مع 'unsafe-inline' لأن الأنماط المضمّنة موجودة في القالب. خطر الأنماط المضمّنة أقلّ بكثير من خطر السكربتات، لكنه ليس صفراً.

font-src نطاقنا ومستودع الخطوط وdata: لأن بعض مكتبات الأيقونات تضمّن خطوطها كبيانات مباشرة.

img-src هنا اتّخذنا قراراً واسعاً بوعي: 'self' data: blob: https:. أي أن أي صورة من أي نطاق آمن مسموحة. السبب أن الموقع يعرض صوراً يضيفها المستخدم أو تأتي من روابط متاجر متغيّرة، وحصرها بقائمة نطاقات يكسر الميزة كل أسبوع. المفاضلة مقبولة لأن الصورة لا تُنفَّذ — أسوأ ما يفعله مصدر صورة خارجي أنه يعرف أن الزائر زار صفحتك، لا أن ينفّذ كوداً.

connect-src تحكم طلبات الشبكة من الجافاسكربت. هنا يُنسى غالباً بروتوكول الويب سوكِت: قاعدة البيانات في الزمن الحقيقي تفتح اتصالاً بـ wss:، وإغفاله يجعل التطبيق يبدو «معلّقاً» بلا رسالة خطأ واضحة. أضِف مخطّط wss: صريحاً لا https: فقط.

worker-src 'self' blob: ضرورية إن كان لديك Service Worker أو عمّال ويب — وبعض المكتبات تنشئ العامل من كائن blob فيُرفض بلا هذه القيمة. ملفّ الخدمة العامل موضوع مستقلّ شرحناه في إدارة إصدارات Service Worker.

المفاضلة الأصعب: النطاقات الدوّارة

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

أمامك ثلاثة خيارات:

  1. حذف CSP كلّها. حلّ الاستسلام، وخسارته أكبر بكثير من ربحه.
  2. فتح script-src لكل النطاقات الآمنة. أسوأ الخيارات — يبطل الغرض الأساسي من السياسة كلّها.
  3. فتح توجيهة واحدة فقط هي frame-src 'self' https:، وترك script-src محصورة بقائمة نطاقات معروفة.

اخترنا الثالث. الفرق جوهري: الإطار الخارجي يعمل في سياق أصله المنفصل، فلا يقرأ نصوص صفحتك ولا كوكيزها ولا يعدّل مستندك. أمّا سكربت مسموح في script-src فيعمل داخل صفحتك بكامل صلاحياتها. فتح الإطارات بلا فتح السكربتات هو أقلّ التنازلات ضرراً.

قاعدة: عند الاضطرار للتوسيع، وسّع أضيق توجيهة تحلّ المشكلة، ولا تلمس script-src أبداً إلا لنطاق تعرفه بالاسم. سؤالك في كل توسيع: هل سيعمل هذا المصدر داخل صفحتي أم في سياق منفصل؟

توجيهات لا نتنازل عنها مهما حدث

  • object-src 'none' — تمنع object وembed. لا استخدام مشروع لها في موقع حديث، وهي ناقل هجوم قديم معروف. صفّرها ولا تفكّر فيها مرّة أخرى.
  • base-uri 'self' — أخطر توجيهة يُغفلها الناس. بلا هذا القيد يستطيع مهاجم حقن وسم base واحد فيحوّل كل المسارات النسبية في صفحتك إلى خادمه، فيسرق كل سكربتاتك وصورك دفعة واحدة بحقن سطر لا يحوي كوداً على الإطلاق.
  • form-action 'self' — تمنع إرسال بيانات نماذجك إلى نطاق خارجي. حماية مباشرة من سرقة بيانات النماذج بحقن سمة واحدة.
  • frame-ancestors 'self' — تمنع أن يضع أحدهم موقعك في إطار داخل موقعه، فتحمي من هجمات النقر المخفي (clickjacking).
  • upgrade-insecure-requests — ترقّي أي طلب غير آمن تلقائياً، فتحمي من رابط قديم منسيّ في مكان ما.

الخطأ الذي كسر تسجيل الدخول

هذا درس دفعنا ثمنه: رأس Cross-Origin-Opener-Policy يُنصح غالباً بضبطه على same-origin لأنه الأشدّ. لكن تسجيل الدخول عبر نافذة منبثقة يحتاج أن تبقى النافذة الأصلية قادرة على التواصل مع النافذة الجديدة لاستلام النتيجة. القيمة الصارمة تقطع هذه الصلة، فتُفتح النافذة، وتُكمل المستخدمة الدخول، ثم لا يحدث شيء — لا خطأ، لا رسالة، لا استجابة.

القيمة الصحيحة هي same-origin-allow-popups: تحفظ العزل عن أي صفحة أخرى، وتستثني النوافذ التي فتحتها أنت. هذا النوع من الفشل الصامت هو الأسوأ في التشخيص، وله أشقّاء كثيرون في مصادقة أندرويد شرحناها في فشل تسجيل الدخول بقوقل.

تشخيص الحجب: أهم مهارة في هذا الملف

حين لا يظهر مورد، لا تخمّن. افتح وحدة تحكّم المتصفّح واقرأ الرسالة، فهي تسمّي التوجيهة التي رفضت والمصدر الذي رُفض بالحرف. رسالة الرفض تبدأ عادةً بعبارة Refused to load أو Refused to frame، وتنتهي بذكر التوجيهة المسؤولة.

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

  • جلبنا ملف السكربت مباشرة: fetch(url).then(r => r.status) فأعطى 200 وحجماً كاملاً.
  • راقبنا حادثة التحميل على الوسم: script.onload فوقعت بنجاح.
  • ووحدة التحكّم خالية من أي رسالة رفض.
  • ومع ذلك الحاوية فارغة.

هذه ليست CSP. السكربت حُمِّل ونُفِّذ ولم يُرجع إعلاناً — وهي مشكلة تجارية لا برمجية، فصّلناها في إعلانات المواقع العربية. القاعدة: لا حجب بلا رسالة. إن كانت وحدة التحكّم نظيفة فسياستك ليست المتّهم.

تحذير: رسائل CSP لا تظهر في وحدة التحكّم إن كنت تستخدم نمط التقرير فقط (Content-Security-Policy-Report-Only) دون قراءة تقاريره. النمط هذا ممتاز للتجربة على موقع حيّ بلا كسره — لكنه بلا قيمة إن لم يقرأ أحد ما يُبلّغ عنه.

الإطار الصديق يورّث سياستك — لا يفلت منها

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

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

رؤوس مكمّلة تُضبط مع CSP

CSP وحدها لا تكفي. هذه الرؤوس رخيصة وتُضاف مرّة واحدة:

  • X-Content-Type-Options: nosniff — يمنع المتصفّح من تخمين نوع المحتوى، فلا يُنفَّذ ملف بيانات ككود.
  • Referrer-Policy: strict-origin-when-cross-origin — لا يُسرّب المسار الكامل لمواقع أخرى. مهمّ في صفحات تحمل معرّفات في المسار.
  • Strict-Transport-Security بمدّة سنتين مع تضمين النطاقات الفرعية — يمنع أول طلب غير آمن ابتداءً.
  • Permissions-Policy بتصفير ما لا تستخدمه: geolocation=(), microphone=(), camera=(), payment=(), usb=(). سطر واحد يسدّ خمس واجهات حسّاسة تماماً، حتى لو نجح مهاجم في تنفيذ كود.

أسئلة شائعة

هل CSP تُبطئ الموقع؟ لا، الفحص يجري في المتصفّح بتكلفة مهملة. بل قد تسرّعه لأنها تمنع تحميل موارد غير مقصودة، وهو ما يخدم تحسين الأداء.

وسم meta أم رأس HTTP؟ الرأس أفضل ويجب تفضيله دائماً: يُطبَّق قبل تحليل المستند، ويدعم توجيهات لا تعمل في الوسم مثل frame-ancestors. الوسم بديل اضطراري حين لا تتحكّم في الرؤوس.

كيف أضبطها على استضافة ثابتة؟ أغلب منصّات الاستضافة الثابتة تتيح تعريف الرؤوس في ملف إعداداتها بمطابقة مسارات. طبّق السياسة على مصادر ملفات HTML وعلى المسار الجذر معاً — إغفال أحدهما يترك صفحة الدخول الرئيسية بلا حماية وهي أهم صفحة عندك.

هل أزيل 'unsafe-inline' يوماً؟ نعم، وهو الهدف. الطريق: انقل كل سكربت مضمّن إلى ملف خارجي، واستبدل سمات onclick بمستمعي أحداث في الكود. حينها تحذف الكلمة وتكسب أهمّ ما تقدّمه CSP فعلاً.

خلاصة

ابدأ بـdefault-src 'self' واكسر موقعك عن قصد، ثم افتح توجيهة واحدة لمصدر واحد وأنت تقرأ رسائل الرفض. لا تلمس script-src إلا لنطاق تعرفه بالاسم، ووسّع frame-src بدلاً منها عند الاضطرار. أبقِ object-src مصفّرة وbase-uri وform-action محصورتين بنطاقك مهما حدث.

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