طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 26 · الوحدة 12: الوقاية من حقن SQL

حقن SQL مع ORM

بيانات المستخدم تبقى بيانات، لا أوامر ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تشرح كيف يغيّر الحقن معنى جملة SQL.
  • تكتشف الإلصاق الخطِر في sequelize.query و literal.
  • تستخدم replacements و bind ودوال ORM.
  • تحمي أسماء الأعمدة بقائمة بيضاء وتضيف طبقات دفاع.

١حين يصبح النص أمرًا

حقن SQL يحدث حين يُلصَق نص المستخدم داخل جملة SQL مباشرة، فيكتب المهاجم رموزًا (مثل ' و -- و OR) تغيّر معنى الجملة نفسها. النتيجة: تجاوز تسجيل الدخول، أو قراءة جداول أخرى، أو حذف البيانات.

ما كتبه المستخدم في حقل الاسمالجملة الناتجة بالإلصاق
saraSELECT * FROM Users WHERE username = 'sara' AND password = '…'
admin' --SELECT * FROM Users WHERE username = 'admin' --' AND password = '…'
كل ما بعد -- تعليق: لا فحص لكلمة المرور!

٢الثغرة في خادم حقيقي

ORM يحميك ما دمت تستخدم دواله. لكن كثيرين يكتبون SQL خامًا بـ sequelize.query ويُلصقون المدخلات بقالب نصي. هذا خادم تسجيل دخول وبحث مكتوب بهذه الطريقة:

const express = require('express');const { Sequelize, DataTypes, QueryTypes } = require('sequelize');const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const User = db.define('User', {  username: DataTypes.STRING,  password: DataTypes.STRING,      // للتبسيط فقط؛ في الحقيقة تجزئة bcrypt  role: DataTypes.STRING,}, { timestamps: false });const Product = db.define('Product', { name: DataTypes.STRING, price: DataTypes.INTEGER }, { timestamps: false }); const app = express();app.use(express.json()); app.post('/api/login', async (req, res) => {  const { username, password } = req.body;  const sql = `SELECT id, username, role FROM Users               WHERE username = '${username}' AND password = '${password}'`;     // ✗ إلصاق  const rows = await db.query(sql, { type: QueryTypes.SELECT });  if (!rows.length) return res.status(401).json({ error: 'بيانات خاطئة' });  res.json({ welcome: rows[0] });}); app.get('/api/search', async (req, res) => {  const sql = `SELECT name, price FROM Products WHERE name LIKE '%${req.query.q}%'`;  // ✗  res.json(await db.query(sql, { type: QueryTypes.SELECT }));}); db.sync().then(async () => {  await User.bulkCreate([    { username: 'admin', password: 'S3cret!', role: 'admin' },    { username: 'sara', password: 'pass123', role: 'student' },  ]);  await Product.bulkCreate([{ name: 'قلم', price: 5 }, { name: 'دفتر', price: 12 }, { name: 'حقيبة', price: 90 }]);  app.listen(3000);});

الطلب الثاني دخل حسابًا للمشرف دون كلمة مرور. والرابع استخدم UNION ليضيف إلى «نتائج البحث» أسماء المستخدمين وكلمات مرورهم من جدول آخر تمامًا!

٣الحل: استعلامات معلَّمة

الحل الجذري ليس «تنظيف» النص، بل فصل الكود عن البيانات: نكتب الجملة بعلامات مكان، ونرسل القيم منفصلة. قاعدة البيانات تعامل القيمة على أنها بيانات دائمًا، مهما احتوت من رموز:

const express = require('express');const { Sequelize, DataTypes, QueryTypes } = require('sequelize');const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const User = db.define('User', {  username: DataTypes.STRING,  password: DataTypes.STRING,      // للتبسيط فقط؛ في الحقيقة تجزئة bcrypt  role: DataTypes.STRING,}, { timestamps: false });const Product = db.define('Product', { name: DataTypes.STRING, price: DataTypes.INTEGER }, { timestamps: false }); const app = express();app.use(express.json());const { Op } = require('sequelize'); // 1) SQL خام مع replacements: علامات :اسم تُستبدل بقيم مهرّبةapp.post('/api/login', async (req, res) => {  const rows = await db.query(    'SELECT id, username, role FROM Users WHERE username = :u AND password = :p',    { replacements: { u: req.body.username, p: req.body.password }, type: QueryTypes.SELECT });  if (!rows.length) return res.status(401).json({ error: 'بيانات خاطئة' });  res.json({ welcome: rows[0] });}); // 2) الأفضل: دوال ORM نفسها (تولّد استعلامًا معلَّمًا تلقائيًّا)app.get('/api/search', async (req, res) => {  res.json(await Product.findAll({    where: { name: { [Op.like]: `%${req.query.q}%` } },    attributes: ['name', 'price'],  }));}); db.sync().then(async () => {  await User.bulkCreate([    { username: 'admin', password: 'S3cret!', role: 'admin' },    { username: 'sara', password: 'pass123', role: 'student' },  ]);  await Product.bulkCreate([{ name: 'قلم', price: 5 }, { name: 'دفتر', price: 12 }, { name: 'حقيبة', price: 90 }]);  app.listen(3000);});

الهجومان فشلا: الاسم admin' -- بحثٌ عن مستخدم بهذا الاسم الحرفي، والبحث بـ UNION بحثٌ عن منتج اسمه يحوي هذا النص. لا تغيّر معنى الجملة.

آمن ✓خطر ✗
Model.findAll({ where: { name: q } })db.query(`… WHERE name = '${q}'`)
db.query('… = :q', { replacements: { q } })Sequelize.literal(`price > ${q}`)
db.query('… = $1', { bind: [q] })order: [[req.query.sort, 'ASC']] بلا قائمة بيضاء

٤ما لا تحميه المعاملات: أسماء الأعمدة

علامات المكان تحمي القيم فقط. أسماء الأعمدة والجداول واتجاه الفرز لا يمكن تمريرها معاملات. إن جاءت من المستخدم (مثل ?sort=price) فاستخدم قائمة بيضاء: اقبل القيم المعروفة فقط:

const express = require('express');const { Sequelize, DataTypes, QueryTypes } = require('sequelize');const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const User = db.define('User', {  username: DataTypes.STRING,  password: DataTypes.STRING,      // للتبسيط فقط؛ في الحقيقة تجزئة bcrypt  role: DataTypes.STRING,}, { timestamps: false });const Product = db.define('Product', { name: DataTypes.STRING, price: DataTypes.INTEGER }, { timestamps: false }); const app = express();app.use(express.json()); const SORTABLE = ['name', 'price'];          // القائمة البيضاء app.get('/api/products', async (req, res) => {  const sort = SORTABLE.includes(req.query.sort) ? req.query.sort : 'name';  const dir = req.query.dir === 'desc' ? 'DESC' : 'ASC';  res.json(await Product.findAll({ order: [[sort, dir]], attributes: ['name', 'price'] }));}); db.sync().then(async () => {  await User.bulkCreate([    { username: 'admin', password: 'S3cret!', role: 'admin' },    { username: 'sara', password: 'pass123', role: 'student' },  ]);  await Product.bulkCreate([{ name: 'قلم', price: 5 }, { name: 'دفتر', price: 12 }, { name: 'حقيبة', price: 90 }]);  app.listen(3000);});

القيمة الثانية الغريبة لم تدخل الجملة أصلًا؛ استُبدلت بالعمود الافتراضي name.

٥طبقات دفاع إضافية

الطبقةكيف
التحقق من المدخلاتارفض ما لا يطابق الشكل المتوقع: الرقم رقم، والبريد بريد (Sequelize validate أو مكتبة مثل express-validator أو zod)
أقل الصلاحياتمستخدم قاعدة البيانات للتطبيق يقرأ ويكتب في جداوله فقط؛ لا DROP ولا إنشاء مستخدمين
رسائل خطأ عامةلا ترسل للمستخدم نص خطأ SQL؛ فهو يكشف بنية الجداول للمهاجم
التجزئةكلمات المرور بـ bcrypt؛ حتى لو تسرّبت لا تُقرأ

توقّع وتحقّق

ما الحل الجذري لحقن SQL؟

Parameterized queries.

هل Product.findAll({ where: { name: q } }) معرّض للحقن؟

ORM.

كيف نحمي الفرز بعمود يختاره المستخدم؟

الأسماء ليست قيمًا.

ماذا يفعل -- في SQL؟

تعليق.

الخلاصة

تحدٍّ برمجي: سد ثغرات الحقن

متقدم

خادم الطلاب فيه ثلاث ثغرات حقن. أصلحها دون تغيير سلوكه للمستخدم العادي.

المطلوب:
  • GET /api/students?major=CS: استبدل الإلصاق بـ replacements أو بدالة ORM.
  • GET /api/students/:id: لا إلصاق للمعرّف.
  • ?sort=: اقبل name أو gpa فقط، وإلا افرز بـ name.
  • لا يبقى في الكود أي قالب نصي ${…} داخل SQL.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. GET /api/students?major=CS&sort=gpa
  2. GET /api/students/2
  3. GET /api/students?major=x' OR '1'='1
  4. GET /api/students/0 OR 1=1
  5. GET /api/students?major=CS&sort=nationalId
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • الاستخدام العادي: طلاب CS مرتبون بالمعدل
  • طالب برقمه
  • major=x' OR '1'='1 لا يعيد أحدًا
  • id = 0 OR 1=1 ← 404
  • الفرز بعمود غير مسموح لا يكشف nationalId
  • … ويفرز بالاسم بدلًا منه
  • لا قوالب ${…} داخل SQL

💡 تلميح 1
أسهل حل: Student.findAll({ where: { major } }).
💡 تلميح 2
للمعرّف: replacements: { id: req.params.id } أو Student.findByPk.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const { Sequelize, DataTypes, QueryTypes } = require('sequelize');const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });const Student = db.define('Student', { name: DataTypes.STRING, major: DataTypes.STRING, gpa: DataTypes.FLOAT, nationalId: DataTypes.STRING }, { timestamps: false });const app = express(); app.get('/api/students', async (req, res) => {  const sort = ['name', 'gpa'].includes(req.query.sort) ? req.query.sort : 'name';  const rows = await Student.findAll({    where: { major: req.query.major || '' },    attributes: ['name', 'major', 'gpa'],    order: [[sort, 'ASC']],  });  res.json(rows);}); app.get('/api/students/:id', async (req, res) => {  const rows = await db.query('SELECT name, major FROM Students WHERE id = :id',    { replacements: { id: req.params.id }, type: QueryTypes.SELECT });  rows.length ? res.json(rows[0]) : res.status(404).json({ error: 'غير موجود' });}); db.sync().then(async () => {  await Student.bulkCreate([    { name: 'ريم', major: 'CS', gpa: 4.6, nationalId: '1001' },    { name: 'فهد', major: 'CS', gpa: 3.9, nationalId: '1002' },    { name: 'هند', major: 'IT', gpa: 4.2, nationalId: '1003' },  ]);  app.listen(3000);});
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

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