العودة إلى المقالات العدد #1  / 

تحصين موقع ثابت

هذا الموقع بلا خادم خلفي ولا تسجيل دخول ولا قاعدة بيانات، ومع ذلك يمكن مهاجمته. إليك ما كان يمكن أن يسوء، وما غيّرته، وكيف اختبرته.

<script> تسلل
المشكلةأي نص يدخل الصفحة على أنه HTML يمكن أن ينفّذ شيفرة في متصفح الزائر.
<img onerror> javascript: <svg onload>
الهجومأدخلت حمولات XSS كلاسيكية في محتوى الموقع نفسه لأرى ما الذي سيُنفَّذ.
CSP محظور.
الحلاعرض كل شيء كنص، واترك المتصفح يفرض ذلك عبر CSP وTrusted Types.

لماذا يحتاج الموقع الثابت إلى الأمان أيضًا

من السهل أن نظن أن الموقع الثابت لا يملك ما يُهاجَم. لا توجد شيفرة خادم للاستغلال ولا قاعدة بيانات للتسريب. لكن الصفحة تشغّل JavaScript في متصفح كل زائر، وهنا يحدث هجوم cross-site scripting (XSS).

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

ما الذي كان يمكن أن يسوء

  • XSS عبر المحتوى: عنوان مقال مثل <img src=x onerror=...> يُنفَّذ بمجرد أن تعرضه الصفحة.
  • روابط خبيثة: رابط من نوع javascript: أو data: ينفّذ شيفرة عند النقر عليه.
  • شيفرة الأطراف الخارجية: الخطوط أو السكربتات المحمّلة من نطاقات أخرى قد تتغير أو تتتبع الزوار أو تُخترق.
  • Reverse tabnabbing: موقع يُفتح في تبويب جديد يمكنه إعادة توجيه تبويبي الأصلي.

ما الذي غيّرته

1. المحتوى نص دائمًا، وليس HTML. كل عنصر يُبنى بـ createElement ويُملأ بـ textContent. يعامل المتصفح البيانات كنص، فيُعرض ما بداخلها من وسوم ولا يُنفَّذ.

2. سياسة Content Security Policy صارمة. لا يحمّل المتصفح السكربتات والأنماط والخطوط والصور إلا من موقعي. السكربتات المضمّنة وeval والإضافات والنماذج كلها محظورة.

default-src 'none'; script-src 'self'; style-src 'self';
font-src 'self'; img-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'none';
require-trusted-types-for 'script'; trusted-types 'none'

3. Trusted Types. السطران الأخيران من السياسة يجعلان المتصفح يرفض أي إسناد HTML خام، مثل innerHTML. حتى لو أعادت تعديلات مستقبلية مني النمط القديم، فسيظهر خطأ بدل أن تنشأ ثغرة.

4. قائمة سماح للروابط. يمر كل رابط عبر دالة واحدة قبل أن يصل إلى الصفحة. لا يمر إلا روابط https: ومسارات موقعي نفسه.

function safeUrl(value) {
  const u = new URL(value, location.href);
  if (u.protocol === "https:") return u.href;
  if (u.origin === location.origin) return u.href;
  return null; // javascript:, data:, anything else is dropped
}

5. بلا أطراف خارجية. الخطوط مستضافة على الموقع نفسه، ولا يرسل الموقع طلبات إلى أي نطاق آخر. لا شيفرة خارجية للوثوق بها ولا تتبع للزوار.

6. التفاصيل الصغيرة. الروابط الخارجية تستخدم rel="noopener noreferrer"، وسياسة المُحيل هي no-referrer، وملف /.well-known/security.txt يبيّن للناس كيف يبلّغون عن مشكلة.

كيف اختبرته

وضعت حمولات حقيقية في بيانات الموقع، وشغّلته محليًا، وحمّلته في Chromium والسياسة مفعّلة.

الحمولةالموضعالنتيجة
<img src=x onerror=...>عنوان المقالعُرضت كنص
<script>...</script>وسم المقالعُرضت كنص
"><svg onload=...>ملخص المقالعُرضت كنص
javascript:...رابط المقال ورابط السيرة الذاتيةأُزيل الرابط
data:text/html,...رابط المقالأُزيل الرابط
<script> مضمّن في HTMLشيفرة الصفحةحظرته CSP
eval()سكربت الموقعحظرته CSP
إسناد إلى innerHTMLالصفحة الحيةحظرته Trusted Types

لم تُنفَّذ أي حمولة. وكررت الفحص الأخير على الموقع الحي بعد نشره.

ما الذي ما زال ناقصًا

لا تتيح GitHub Pages ضبط ترويسات استجابة HTTP. بعض الحمايات تعمل كترويسات فقط وليس كوسم <meta>: frame-ancestors ضد clickjacking، وStrict-Transport-Security وPermissions-Policy. الخطر منخفض لأن الموقع لا يحتوي ما يمكن خداع أحد للنقر عليه، لكنها تبقى ثغرة.

الحل أن أقدّم الموقع من مكان أتحكم فيه بالترويسات. وهذا موضوع العدد القادم.