لماذا يحتاج الموقع الثابت إلى الأمان أيضًا
من السهل أن نظن أن الموقع الثابت لا يملك ما يُهاجَم. لا توجد شيفرة خادم للاستغلال ولا قاعدة بيانات للتسريب. لكن الصفحة تشغّل 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. الخطر منخفض لأن الموقع لا يحتوي ما يمكن خداع أحد للنقر عليه، لكنها تبقى ثغرة.
الحل أن أقدّم الموقع من مكان أتحكم فيه بالترويسات. وهذا موضوع العدد القادم.