Kodax

حقن SQL والاستعلامات المعاملية — SQL الأساسيات

حقن SQL والاستعلامات المعاملية

أهم درس أمني لأي مطور يكتب SQL. الثغرة قديمة ومعروفة، وما زالت من أكثر الثغرات انتشاراً وأخطرها.

المشكلة: لصق مدخلات المستخدم في نص الاستعلام

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, is_admin INTEGER);
INSERT INTO users (email, is_admin) VALUES ('sara@example.com', 0), ('admin@example.com', 1);
""")

def find_user_unsafe(email):
    query = f"SELECT id, email FROM users WHERE email = '{email}'"   # ❌ لصق مباشر
    return db.execute(query).fetchall()

print(find_user_unsafe("sara@example.com"))
print(find_user_unsafe("x' OR '1'='1"))      # يعيد كل المستخدمين!

المدخل x' OR '1'='1 يغلق النص ويضيف شرطاً صحيحاً دائماً، فيتغير معنى الاستعلام. بنفس الطريقة يمكن قراءة جداول أخرى، أو تعديل البيانات، أو تجاوز تسجيل الدخول — حسب صلاحيات حساب قاعدة البيانات.

الحل: الاستعلامات المعاملية (Parameterized Queries)

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, is_admin INTEGER);
INSERT INTO users (email, is_admin) VALUES ('sara@example.com', 0), ('admin@example.com', 1);
""")

def find_user(email):
    return db.execute("SELECT id, email FROM users WHERE email = ?", (email,)).fetchall()   # ✅

print(find_user("sara@example.com"))
print(find_user("x' OR '1'='1"))             # [] — يُعامل كنص عادي

القاعدة تستقبل بنية الاستعلام والقيم منفصلتين؛ القيمة لا يمكن أبداً أن تصبح جزءاً من أوامر SQL مهما احتوت.

في Node.js

// node-postgres: $1, $2
// await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// mysql2: ?
// await conn.execute("SELECT id, email FROM users WHERE email = ?", [email]);
const email = "x' OR '1'='1";
const params = [email];   // القيمة تُرسل منفصلة عن نص الاستعلام
console.log(params);

⚠️ القوالب النصية في JavaScript ليست معاملات: كتابة ${email} داخل نص الاستعلام (بين علامتي `) مصابة بنفس الثغرة — مرّر القيمة في مصفوفة المعاملات دائماً.

ما لا يمكن تمريره كمعامل

المعاملات للـقيم فقط، لا لأسماء الأعمدة والجداول أو اتجاه الترتيب. لترتيب ديناميكي استخدم قائمة بيضاء:

ALLOWED_SORT = {"name": "name", "newest": "created_at DESC"}

def order_clause(user_choice):
    return "ORDER BY " + ALLOWED_SORT.get(user_choice, "id")   # لا يمر نص المستخدم أبداً

print(order_clause("newest"), "|", order_clause("name; DROP TABLE users"))

ORMs و Query Builders

Prisma و Sequelize و SQLAlchemy و Knex تستخدم المعاملات تلقائياً في عملياتها العادية. الخطر يعود عند استخدام دوال الاستعلام الخام ($queryRawUnsafe، raw()، text() مع لصق نصوص) — استخدم نسخها الآمنة التي تقبل معاملات.

طبقات حماية إضافية (لا تغني عن المعاملات)

| الطبقة | الفائدة |

|---|---|

| أقل صلاحيات لحساب قاعدة البيانات | حساب التطبيق لا يملك DROP ولا الوصول لجداول لا يحتاجها |

| التحقق من المدخلات | رفض المعرّف إن لم يكن رقماً قبل الوصول للقاعدة |

| رسائل أخطاء عامة | لا ترسل أخطاء SQL للعميل؛ تكشف بنية الجداول |

| المراقبة | سجلات تنبه على أنماط استعلامات غريبة |

تمرين

حوّل دالة البحث في المثال الكامل التي تجمع عدة فلاتر اختيارية (الاسم، المدينة، الحد الأدنى للمبلغ) إلى نسخة معاملية تبني شروط WHERE وقائمة المعاملات معاً، وجرّب عليها المدخلات الخبيثة.

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, city TEXT, spent REAL);
INSERT INTO users (name, city, spent) VALUES
  ('سارة', 'بغداد', 250), ('عمر', 'البصرة', 90), ('ليلى', 'بغداد', 40), ('زيد', 'أربيل', 600);
""")

ALLOWED_SORT = {"name": "name", "spent": "spent DESC"}


def search_users(name=None, city=None, min_spent=None, sort="name"):
    """بحث بفلاتر اختيارية: الشروط والمعاملات تُبنى معاً — لا لصق لمدخلات المستخدم"""
    conditions, params = [], []
    if name:
        conditions.append("name LIKE ?")
        params.append(f"%{name}%")          # الـ % جزء من القيمة لا من الاستعلام
    if city:
        conditions.append("city = ?")
        params.append(city)
    if min_spent is not None:
        conditions.append("spent >= ?")
        params.append(float(min_spent))

    sql = "SELECT name, city, spent FROM users"
    if conditions:
        sql += " WHERE " + " AND ".join(conditions)
    sql += " ORDER BY " + ALLOWED_SORT.get(sort, "id")   # قائمة بيضاء لما لا يُمرَّر كمعامل
    return db.execute(sql, params).fetchall()


print("بغداد:", search_users(city="بغداد"))
print("أنفق 100+ مرتباً:", search_users(min_spent=100, sort="spent"))

# مدخلات خبيثة تُعامل كنصوص عادية
print("حقن في المدينة:", search_users(city="x' OR '1'='1"))
print("حقن في الترتيب:", search_users(sort="name; DROP TABLE users"))
print("الجدول ما زال موجوداً:", db.execute("SELECT COUNT(*) FROM users").fetchone()[0], "صفوف")
اختبر فهمك