حقن SQL مع ORM
بيانات المستخدم تبقى بيانات، لا أوامر ⏱ نحو 45 دقيقة.
- تشرح كيف يغيّر الحقن معنى جملة
SQL. - تكتشف الإلصاق الخطِر في
sequelize.queryوliteral. - تستخدم
replacementsوbindودوالORM. - تحمي أسماء الأعمدة بقائمة بيضاء وتضيف طبقات دفاع.
١حين يصبح النص أمرًا
حقن SQL يحدث حين يُلصَق نص المستخدم داخل جملة SQL مباشرة، فيكتب المهاجم رموزًا (مثل ' و -- و OR) تغيّر معنى الجملة نفسها. النتيجة: تجاوز تسجيل الدخول، أو قراءة جداول أخرى، أو حذف البيانات.
| ما كتبه المستخدم في حقل الاسم | الجملة الناتجة بالإلصاق |
|---|---|
sara | SELECT * 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؟
الخلاصة
- الإلصاق = ثغرة: المدخلات تصبح جزءًا من الكود.
- الاستعلام المعلَّم يفصل الكود عن القيم:
replacementsأوbind. - دوال
ORMآمنة افتراضيًّا؛literalوqueryبالقوالب ليست كذلك. - أسماء الأعمدة والاتجاه: قائمة بيضاء.
- تحقق من المدخلات، وأقل صلاحيات، ورسائل خطأ عامة.
تحدٍّ برمجي: سد ثغرات الحقن
متقدمخادم الطلاب فيه ثلاث ثغرات حقن. أصلحها دون تغيير سلوكه للمستخدم العادي.
GET /api/students?major=CS: استبدل الإلصاق بـ replacements أو بدالة ORM.GET /api/students/:id: لا إلصاق للمعرّف.?sort=: اقبلnameأوgpaفقط، وإلا افرز بـ name.- لا يبقى في الكود أي قالب نصي
${…}داخل SQL.
👀 اعرض الاستجابات المطلوبة
GET /api/students?major=CS&sort=gpaGET /api/students/2GET /api/students?major=x' OR '1'='1GET /api/students/0 OR 1=1GET /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);});