XSS في React و Express
لا تدع نص المستخدم يتحول إلى كود في صفحتك ⏱ نحو 45 دقيقة.
- تشرح أنواع
XSSالثلاثة وأثرها. - تهرّب مدخلات المستخدم في قوالب الخادم.
- تعرف متى يحميك
Reactومتى لا، وتستخدمDOMPurify. - تضيف طبقات دفاع:
CSPبـhelmet، وكوكيزhttpOnly، وفحص الروابط.
١ما XSS؟
البرمجة النصية عبر المواقع Cross-Site Scripting: يُدخل المهاجم نصًّا فيه كود (HTML أو JavaScript) في مكان يعرضه موقعك لزوّار آخرين، فيعامله المتصفح على أنه جزء من صفحتك ويشغّله. والكود عندها يعمل بصلاحيات موقعك: يقرأ الرموز من localStorage، ويرسل طلبات باسم المستخدم، ويغيّر الصفحة.
| النوع | أين يُحفظ الكود الخبيث | مثال |
|---|---|---|
| المنعكس Reflected | في الرابط، ويُعرض في الرد مباشرة | /search?q=<script>… يُرسل للضحية |
| المخزَّن Stored | في قاعدة البيانات | تعليق خبيث يظهر لكل من يفتح الصفحة |
| في DOM | كود الواجهة يضع مدخلات في innerHTML | el.innerHTML = location.hash |
القاعدة الذهبية: كل ما يأتي من المستخدم بيانات لا كود. عند عرضه في HTML يجب «تهريبه» Escape: < تصبح < فيظهر الرمز نصًّا ولا يُفسَّر وسمًا.
٢ثغرة في خادم Express
هذا خادم يعرض عبارة البحث في صفحة HTML بقالب نصي. الطلب يحمل في الرابط وسم صورة خبيثًا. انظر ماذا حدث للصفحة في المتصفح المصغّر (المعاينة معزولة، فلا خطر عليك):
const express = require('express');const app = express(); app.get('/search', (req, res) => { const q = req.query.q || ''; res.send(`<h1>نتائج البحث عن: ${q}</h1><p>لا نتائج.</p>`); // ✗ خطير}); app.listen(3000);
والإصلاح: نهرّب الرموز الخمسة الخاصة قبل وضع أي قيمة في HTML. محرّكات القوالب (EJS و Pug و Handlebars) تفعل هذا تلقائيًّا، وكذلك React:
const express = require('express');const app = express(); function escapeHtml(s) { return String(s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, ''');} app.get('/search', (req, res) => { const q = req.query.q || ''; res.send(`<h1>نتائج البحث عن: ${escapeHtml(q)}</h1><p>لا نتائج.</p>`); // ✓}); app.listen(3000);
الكود الخبيث ظهر نصًّا عاديًّا. انظر في «المصدر»: الرموز صارت < و >.
٣React يحميك افتراضيًّا… إلا إن طلبت غير ذلك
كل قيمة تضعها بين { } في JSX يهرّبها React تلقائيًّا، فهي آمنة. الخطر في ثلاثة مواضع: dangerouslySetInnerHTML، والروابط href التي يكتبها المستخدم، والتعامل المباشر مع DOM (innerHTML). جرّب الأزرار الثلاثة:
import { useState } from 'react';import DOMPurify from 'dompurify'; const comment = '<b>رائع</b> <img src=x onerror="document.body.style.background=\'#ffcdd2\'">'; export default function App() { const [mode, setMode] = useState('safe'); return ( <div> <button onClick={() => setMode('safe')}>{'{ }'} عادي</button> <button onClick={() => setMode('danger')}>dangerously…</button> <button onClick={() => setMode('purify')}>DOMPurify</button> <div className="box"> {mode === 'safe' && <p>{comment}</p>} {mode === 'danger' && <p dangerouslySetInnerHTML={{ __html: comment }} />} {mode === 'purify' && <p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment) }} />} </div> </div> );}
«عادي» يعرض الوسوم نصًّا. «dangerously» ينفّذ onerror فتتلون الصفحة. «DOMPurify» يُبقي التنسيق الآمن (الخط العريض) ويحذف الخاصية الخطيرة.
والروابط: الرابط javascript:… ينفّذ كودًا عند النقر. افحص البروتوكول قبل عرض رابط كتبه مستخدم:
function safeUrl(url) { try { const u = new URL(url, window.location.origin); return ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : '#'; } catch { return '#'; }} <a href={safeUrl(user.website)} rel="noopener noreferrer" target="_blank">موقعي</a>
٤خطوط الدفاع الإضافية
التهريب هو الدفاع الأول. وتضيف هذه الطبقات حماية إن فات الأول شيء:
| الطبقة | ما تفعله |
|---|---|
| CSP سياسة أمان المحتوى | ترويسة تخبر المتصفح من أين يُسمح بتحميل السكربتات؛ السكربتات المحقونة في الصفحة (inline) تُمنع |
كوكي httpOnly | رمز الجلسة لا يقرؤه JavaScript، فلا يسرقه XSS |
| التحقق من المدخلات | رفض ما لا يطابق الشكل المتوقع (بريد، رقم…) عند الاستلام |
| أقل الصلاحيات | حتى لو نُفّذ كود، لا يستطيع فعل الكثير |
حزمة helmet تضبط CSP وعشر ترويسات أمان أخرى بسطر واحد:
const express = require('express');const helmet = require('helmet');const app = express(); app.use(helmet()); // CSP و X-Content-Type-Options و Strict-Transport-Security و… app.get('/', (req, res) => res.send('<h1>صفحة محمية</h1>')); app.listen(3000);
افتح تبويب 📨 HTTP: الترويسة Content-Security-Policy تمنع السكربتات المضمّنة inline ومن مصادر غريبة، و X-Frame-Options تمنع تضمين موقعك في إطار موقع آخر.
توقّع وتحقّق
ما أصل ثغرة XSS؟
هل {userText} في JSX آمن؟
متى يكون dangerouslySetInnerHTML مقبولًا؟
ما الذي يمنع سرقة رمز الجلسة بـ XSS؟
JS.الخلاصة
XSS= بيانات المستخدم تُفسَّر كودًا في صفحتك.- هرّب & < > " ' قبل وضع أي قيمة في
HTML. JSXيهرّب تلقائيًّا؛ الخطر فيdangerouslySetInnerHTMLوhrefوinnerHTML.HTMLمن المستخدم ←DOMPurify.sanitizeدائمًا.helmet (CSP)وhttpOnlyطبقات إضافية لا بديل عن التهريب.
تحدٍّ برمجي: لوحة تعليقات آمنة
متوسطلوحة تعليقات تعرض التعليقات في صفحة HTML. مهاجم أضاف تعليقًا خبيثًا. أصلح الخادم.
- اكتب دالة
escapeHtmlواستخدمها لكل قيمة من المستخدم (الاسم والنص) قبل وضعها في HTML. - أضف
helmet()لترويسات الأمان. - بعد الإصلاح: التعليق الخبيث يظهر نصًّا (
<img) ولا يحتوي الرد وسم<imgحقيقيًّا.
👀 اعرض الاستجابات المطلوبة
POST /commentsname=مهاجم"><script>x()</script>&text=<img+src=x+onerror=alert(1)>GET /
- الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
- لا وسم
- الخبيث يظهر نصًّا مهرّبًا
- التعليق الأصلي ما زال يظهر
- ترويسة CSP من helmet
- الإضافة تحوّل للصفحة
(302)
💡 تلميح 1
💡 تلميح 2
escapeHtml على c.name و c.text معًا.حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
const express = require('express');const helmet = require('helmet');const app = express();app.use(helmet());app.use(express.urlencoded({ extended: false })); const comments = [{ name: 'سارة', text: 'شرح ممتاز' }];const escapeHtml = (s) => String(s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, '''); app.get('/', (req, res) => { const list = comments.map(c => `<li><b>${escapeHtml(c.name)}</b>: ${escapeHtml(c.text)}</li>`).join(''); res.send(`<h1>التعليقات</h1><ul>${list}</ul> <form method="post" action="/comments"><input name="name"><input name="text"><button>أضف</button></form>`);}); app.post('/comments', (req, res) => { comments.push({ name: req.body.name, text: req.body.text }); res.redirect('/');}); app.listen(3000);