طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 25 · الوحدة 11: تقليل أثر البرمجة النصية عبر المواقع XSS

XSS في React و Express

لا تدع نص المستخدم يتحول إلى كود في صفحتك ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تشرح أنواع XSS الثلاثة وأثرها.
  • تهرّب مدخلات المستخدم في قوالب الخادم.
  • تعرف متى يحميك React ومتى لا، وتستخدم DOMPurify.
  • تضيف طبقات دفاع: CSP بـ helmet، وكوكيز httpOnly، وفحص الروابط.

١ما XSS؟

البرمجة النصية عبر المواقع Cross-Site Scripting: يُدخل المهاجم نصًّا فيه كود (HTML أو JavaScript) في مكان يعرضه موقعك لزوّار آخرين، فيعامله المتصفح على أنه جزء من صفحتك ويشغّله. والكود عندها يعمل بصلاحيات موقعك: يقرأ الرموز من localStorage، ويرسل طلبات باسم المستخدم، ويغيّر الصفحة.

النوعأين يُحفظ الكود الخبيثمثال
المنعكس Reflectedفي الرابط، ويُعرض في الرد مباشرة/search?q=<script>… يُرسل للضحية
المخزَّن Storedفي قاعدة البياناتتعليق خبيث يظهر لكل من يفتح الصفحة
في DOMكود الواجهة يضع مدخلات في innerHTMLel.innerHTML = location.hash

القاعدة الذهبية: كل ما يأتي من المستخدم بيانات لا كود. عند عرضه في HTML يجب «تهريبه» Escape: < تصبح &lt; فيظهر الرمز نصًّا ولا يُفسَّر وسمًا.

٢ثغرة في خادم 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');} app.get('/search', (req, res) => {  const q = req.query.q || '';  res.send(`<h1>نتائج البحث عن: ${escapeHtml(q)}</h1><p>لا نتائج.</p>`);   // ✓}); app.listen(3000);

الكود الخبيث ظهر نصًّا عاديًّا. انظر في «المصدر»: الرموز صارت &lt; و &gt;.

٣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.

الخلاصة

تحدٍّ برمجي: لوحة تعليقات آمنة

متوسط

لوحة تعليقات تعرض التعليقات في صفحة HTML. مهاجم أضاف تعليقًا خبيثًا. أصلح الخادم.

المطلوب:
  • اكتب دالة escapeHtml واستخدمها لكل قيمة من المستخدم (الاسم والنص) قبل وضعها في HTML.
  • أضف helmet() لترويسات الأمان.
  • بعد الإصلاح: التعليق الخبيث يظهر نصًّا (&lt;img) ولا يحتوي الرد وسم <img حقيقيًّا.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. POST /comments name=مهاجم"><script>x()</script>&text=<img+src=x+onerror=alert(1)>
  2. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')  .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 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);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس