Menu
flag Ar iconالعربيةdown icon

حماية SQLite من حقن SQL: الاستعلامات المُعاملية

لماذا يُعدّ دمج النصوص خطرًا حقيقيًا، وكيف تحدث هجمات حقن SQL فعليًا، وكيف تُغلق الاستعلامات المُعاملية في SQLite هذا الباب نهائيًا.

تحتوي هذه الصفحة على محررات قابلة للتشغيل — حرّر، شغّل، وشاهد النتيجة فوراً.

حقن SQL في SQLite ببساطة هو خطأ في بناء النصوص

ثغرة حقن SQL تحصل لمّا مدخلات المستخدم تنتهي كجزء من نص استعلام SQL الذي تُحلِّله قاعدة البيانات. وبمجرّد ما يضيع الحدّ الفاصل — يعني القيمة التي كتبها المستخدم تتحوّل إلى صياغة (syntax) تُنفِّذها قاعدة البيانات — يصير بإمكانه يعمل أي شيء أنت قادر تعمله.

هذا هو المثال الكلاسيكي على هجوم SQL injection، مكتوب بصيغة شبه-كود يقدر أي لغة تُنتجه:

-- لا تفعل هذا
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

إذا كانت قيمة user_input هي Ada، فسيتم جلب السجل بشكل طبيعي. أما إذا كانت قيمة user_input هي ' OR 1=1 --، فستحصل على:

SELECT * FROM users WHERE name = '' OR 1=1 --'

علامتا -- تُحوِّلان علامة الاقتباس المتبقية إلى تعليق، وتعبير OR 1=1 يُطابق كل صف، وبهذا يكون المهاجم قد سرب جدول المستخدمين بالكامل. وفي صور أسوأ، يربط المهاجم ; بعبارة ثانية لإسقاط جداول أو تسريب بيانات أو إضافة حساب مسؤول جديد.

الثغرة ليست في SQLite نفسه، بل في الكود الذي ركّب تلك السلسلة النصية.

الاستعلامات المُعاملية: الحل الفعلي للحماية من SQL Injection

الـ parameterized query في SQLite يفصل نص الـ SQL عن القيم. يحتوي الاستعلام على عناصر نائبة — ? أو :name — وأنت تُمرِّر القيم بشكل منفصل. يقوم SQLite بتحليل الاستعلام وتجميعه مرة واحدة، ثم يربط قيمك (bound parameters) داخل الخطة المُجمَّعة. وبهذه الطريقة يستحيل أن تتحول القيم إلى أوامر SQL.

لنُنفِّذ نفس عملية البحث التي بدت قابلة للاختراق، ولكن بالطريقة الآمنة:

في صدفة SQLite تكتب القيمة مباشرةً، لكن في كود تطبيقك يكون المُكافئ بهذا الشكل (باستخدام مكتبة sqlite3 في بايثون):

# بايثون — مُعاملات آمنة
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

مرّر استعلام SQL ومجموعة القيم (tuple) كوسيطين منفصلين. يقوم الدرايفر بإرسالهما إلى SQLite كلٌّ على حدة. وحتى لو كانت قيمة user_input هي ' OR 1=1 --، فإن SQLite سيبحث عن مستخدم اسمه حرفياً ' OR 1=1 -- ولن يجد شيئاً.

ماذا تعني كلمة "آمن" هنا فعلياً؟

الأمان هنا لا يعتمد على مطابقة الأنماط أو على تهريب مدخلات المستخدم في SQL، بل هو أمان بنيوي. فـ SQLite يُترجم العبارة إلى صيغة داخلية مُجهّزة (prepared statement) قبل أن يرى قيمتك أصلاً:

-- العبارة المُجمَّعة تحتوي على خانة، وليس على سلسلة نصية.
SELECT * FROM users WHERE name = ?
                                 ^
                                 خانة العنصر النائب

عند ربط قيمة ما، فإنها تُمرَّر إلى موضعها كبيانات ذات نوع محدد — TEXT أو INTEGER أو BLOB أو غيرها. ولن يُعيد SQLite تحليلها كأنها كود SQL أبداً. ببساطة، لا يوجد أي صياغة يستطيع المهاجم من خلالها حقن أوامر، لأن المُحلِّل (parser) قد أنهى عمله قبل أن تصل القيمة أصلاً.

ولهذا السبب تحديداً تُعدّ الاستعلامات المُعاملية (parameterized queries) وسيلةً موثوقة بشكل لا يمكن لأسلوب التهريب (escaping) أن يضاهيه. التهريب يحاول تنظيف المدخلات من المحارف الخطرة، أما الربط (binding) فلا يسمح ببناء السلسلة الخطرة من الأساس.

ابتعد عن تنسيق النصوص لبناء استعلامات SQL

كل لغة برمجة فيها اختصار مُغرٍ — f-strings في بايثون، وtemplate literals في جافاسكربت، وString.format في جافا — وكل واحد منها قنبلة موقوتة حين يتعلق الأمر بـ SQL.

# لا تفعل — السلسلة المنسقة (f-string) تُدرج القيمة داخل نص SQL
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# لا تفعل — نفس المشكلة، التنسيق باستخدام %
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

# افعل — استخدم العنصر النائب مع وسيطة القيم
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

الطريقتان الأوليان تَدمِجان مُدخلات المستخدم مباشرةً في نص استعلام SQL قبل أن يراه المُحرِّك أصلاً. فعندما يصل الاستعلام إلى SQLite، تكون الكارثة قد وقعت بالفعل. أما الطريقة الثالثة، فتفصل بين جملة SQL والقيم المُمرَّرة إليها، كلٌّ في مساره الخاص.

القاعدة هنا ميكانيكية بحتة: في أي لحظة تجد نفسك فيها تبني نص SQL باستخدام + أو f-strings أو format أو القوالب النصية لإدراج قيمة ما — توقَّف فوراً واستخدم placeholder بدلاً من ذلك.

استخدام عدة معاملات و named placeholders

الاستعلامات في المشاريع الحقيقية نادراً ما تحتوي على قيمة واحدة فقط. ولحسن الحظ، يدعم SQLite نوعين من المعاملات: المعاملات الموضعية ? والمعاملات المُسمّاة :name:

في كود التطبيق، تُترجَم هذه إلى:

# الموضعية
cursor.execute(
    "SELECT * FROM orders WHERE customer = ? AND status = ?",
    ("Ada", "paid"),
)

# المُسمّاة — أوضح عندما يكون هناك عدة معاملات
cursor.execute(
    "SELECT * FROM orders WHERE total > :min_total AND status = :status",
    {"min_total": 50, "status": "paid"},
)

المعاملات المُسمّاة (named parameters) تتوسّع بشكل أفضل. بمجرد ما تتجاوز ثلاث أو أربع قيم، يتحوّل ?, ?, ?, ? إلى لعبة تخمين، بينما :customer, :total, :status, :created_at يشرح نفسه بنفسه.

المُعرّفات تحتاج أسلوبًا مختلفًا

الـ bound parameters تشتغل فقط مع القيم — أي الأشياء التي تأتي بعد =، أو داخل IN (...)، أو في VALUES (...). أما أسماء الجداول وأسماء الأعمدة والكلمات المفتاحية مثل ASC وDESC، فلا يمكن تمريرها كمعاملات.

-- هذا لا يعمل. لا يمكن للعنصر النائب أن يحلّ محل اسم العمود.
SELECT * FROM users ORDER BY ? ASC

إذا كنت بحاجة إلى معرّف ديناميكي — مثلاً السماح للمستخدم باختيار العمود الذي يريد الفرز حسبه — فتحقّق منه مقابل قائمة مسموح بها قبل بناء جملة SQL:

# نهج القائمة المسموح بها
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}

if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError(f"عمود فرز غير صالح: {sort_column}")

query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)

يُفحَص النص الذي يُدخله المستخدم مقابل قائمة محدودة من القيم الآمنة المعروفة قبل أن يقترب من جملة SQL أصلًا. واستخدام f-string هنا مقبول فقط لأن sort_column لم يعد بإمكانه أن يحمل أي قيمة سوى أحد الأسماء الثلاثة المُثبَّتة في الكود.

مثال عملي على هجوم SQL injection وكيفية إفشاله

لنضع النسختين جنبًا إلى جنب أمام مدخل خبيث. أنشئ جدول users بسيطًا:

النموذج المكشوف يُعيد جميع المستخدمين، أما النموذج المعتمد على الاستعلامات المُعاملية فيبحث حرفياً عن مستخدم اسمه ' OR 1=1 -- ولا يجد شيئاً. نفس المُدخل، ونتيجة مختلفة تماماً — لأن القيمة في الحالة الثانية لم تتحول أصلاً إلى كود SQL.

قائمة تحقق سريعة للحماية من SQL Injection

  • استخدم العلامات النائبة ? أو :name مع كل قيمة قادمة من خارج الكود — مدخلات المستخدم، أجسام الطلبات، متغيرات البيئة، أي شيء لم تكتبه أنت بنفسك داخل الاستعلام.
  • لا تُركّب جملة SQL باستخدام + أو f-strings أو format في موضع تمرير القيم.
  • إذا احتجت أسماء جداول أو أعمدة ديناميكية، تحقق منها مقابل قائمة سماح ثابتة (allowlist) قبل دمجها في الاستعلام.
  • ثِق بالـ driver. لا تكتب دالة تهريب اقتباسات خاصة بك؛ فآلية bound parameters أقدم وأكثر اختباراً في الميدان وهي الصواب.
  • راجع استعلامات فريقك بسؤال واحد: هل تُدمج أي مدخلات من المستخدم داخل نص SQL مباشرة؟ إن كانت الإجابة نعم، فأصلِحها فوراً.

اجعل هذه العادة جزءاً من أصابعك أثناء الكتابة، وسيتوقف هجوم SQL injection عن كونه نوعاً من الأخطاء يستهلك تفكيرك.

التالي: الاتصال من داخل التطبيقات

رأيت الآن الشكل الآمن للاستعلام — علامة نائبة داخل الـ SQL، والقيمة تُمرَّر بجانبها. الصفحة التالية تشرح كيف تربط SQLite فعلياً من كود تطبيق حقيقي في Python وNode.js وغيرهما، مع إدارة الاتصالات وموقع الاستعلامات المُعاملية ضمن دورة الطلب المعتاد.

الأسئلة الشائعة

هل قاعدة بيانات SQLite معرّضة لهجمات حقن SQL؟

نعم، وبنفس القدر الذي تتعرض له أي قاعدة بيانات SQL أخرى متى ما كان كود التطبيق يبني الاستعلامات عبر دمج النصوص. الحل ليس إعدادًا داخل SQLite نفسها، بل في طريقة تمرير القيم من تطبيقك. استخدم الاستعلامات المُعاملية مع ? أو :name ودع المُشغّل (driver) يتولى الباقي بأمان.

كيف تمنع الاستعلامات المُعاملية حقن SQL؟

عند استخدام عناصر نائبة مثل ?، يقوم SQLite أولًا بتحليل الاستعلام وترجمته (compile)، ثم يربط القيم في خانات داخل الاستعلام المُترجَم مسبقًا. هذه القيم لا يمكن أن تتحول إلى صياغة SQL بأي حال، فهي تُعامَل كبيانات فقط، ولا يوجد نص يستطيع المهاجم الخروج منه.

ألا يكفي أن أُهرّب علامات الاقتباس في مدخلات المستخدم؟

لا. التهريب اليدوي هش جدًا، فلا بد أن تفوتك حالة ما (علامات اقتباس Unicode، حيل الترميز، رموز التعليقات) وتطلق ثغرة من حيث لا تدري. المُشغّلات توفّر ? و :name تحديدًا حتى لا تضطر للتفكير في التهريب أصلًا. استخدمها في كل مرة، حتى مع القيم التي تظنها 'آمنة'.

ماذا عن أسماء الجداول أو الأعمدة القادمة من المستخدم؟

المُعاملات المربوطة (bound parameters) تعمل مع القيم فقط، لا مع المُعرّفات (identifiers). إذا اضطررت لجعل اسم جدول أو عمود ديناميكيًا، تحقق منه مقابل قائمة بيضاء (allowlist) من الأسماء المعروفة قبل دمجه في الـ SQL. لا تمرّر أبدًا مُعرّفًا قادمًا من المستخدم مباشرة عبر تنسيق النصوص.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن