JDBC ونمط DAO

تجنب حقن SQL وأفضل الممارسات

18 دقيقة الدرس 9 من 13

تجنب حقن SQL وأفضل الممارسات

احتلّ حقن SQL المرتبة الأولى أو المراتب القريبة منها في قائمة OWASP Top 10 لأكثر من عقدين — ليس لأنه يصعب التصدي له، بل لأن المطورين يستمرون في دمج مدخلات المستخدم مع سلاسل SQL. يُرسّخ هذا الدرس طبقات الدفاع التي يجب أن يمتلكها أي تطبيق JDBC احترافي: التحديد بمعاملات كضابط أساسي، والتحقق من صحة المدخلات كدفاع ثانوي، وحسابات قاعدة البيانات محدودة الصلاحيات، وجملة من أنظمة البرمجة التي تُقضي على فئات كاملة من الثغرات.

ما هو حقن SQL فعليًا

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

// لا تفعل هذا أبدًا String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

إذا أدخل أحد المهاجمين admin' -- بوصفها اسم مستخدم، يصبح SQL المُنتج:

SELECT * FROM users WHERE username = 'admin' --' AND password = '...'

الشرطتان المزدوجتان تُعلّقان التحقق من كلمة المرور. تُعاد كل صف تكون فيه username = 'admin' متجاوزًا المصادقة كليًا. الحمولات الأشد تدميرًا قادرة على تنفيذ DROP TABLE، أو تسريب جميع الصفوف، أو استدعاء إجراءات مخزّنة تُعدّل ملفات على مستوى نظام التشغيل.

الدفاع الأساسي: التحديد بمعاملات مع PreparedStatement

PreparedStatement هو خط الأساس الذي لا يمكن التنازل عنه. عندما تُمرّر معاملًا عبر مُعيِّن مكتوب، يُرسل مشغّل JDBC قالب SQL والقيمة على شكل رسالتين شبكيتين منفصلتين. لا تحلّل قاعدة البيانات القيمة أبدًا باعتبارها SQL — بل تتعامل معها باعتبارها سلسلة حرفية أو رقمًا أو كتلة بيانات ثنائية.

// صحيح — بمعاملات محددة؛ الحقن مستحيل هيكليًا String sql = "SELECT * FROM users WHERE username = ? AND password_hash = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, hashPassword(rawPassword)); // دائمًا هشّ كلمات المرور try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return Optional.of(mapRow(rs)); } } } return Optional.empty();
يعمل التحديد بمعاملات بفضل الفصل على مستوى البروتوكول. يُصرَّف قالب SQL مرةً واحدة؛ تصل قيم الربط في خطوة لاحقة. لا توجد سلسلة يستطيع المهاجم بناؤها كقيمة مدخلة يمكن أن تُفسَّر في أي وقت كصيغة SQL — ببساطة لا تحلّل قاعدة البيانات خانة القيمة.

الجمل الديناميكية: قوائم IN والأعمدة المُرتَّبة

يُغطي التحديد بمعاملات القيم القياسية بسهولة. ثمة نمطان يُوقعان المطورين الذين فهموا الأساسيات بالفعل:

قوائم IN ذات الطول المتغير. لا يمكن ربط قائمة كاملة بـ ? واحدة. ابنِ سلسلة العناصر النائبة برمجيًا، ثم اربط كل عنصر منفردًا:

public List<Product> findByIds(Connection conn, List<Integer> ids) throws SQLException { if (ids.isEmpty()) return List.of(); // بناء "?,?,?" بعدد من العناصر النائبة يساوي ids.size() String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); String sql = "SELECT id, name, price FROM products WHERE id IN (" + placeholders + ")"; try (PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < ids.size(); i++) { ps.setInt(i + 1, ids.get(i)); // لا يزال بمعاملات محددة — آمن } try (ResultSet rs = ps.executeQuery()) { List<Product> result = new ArrayList<>(); while (rs.next()) result.add(mapRow(rs)); return result; } } }

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

private static final Set<String> ALLOWED_SORT_COLUMNS = Set.of("name", "price", "stock", "created_at"); public List<Product> findAllSorted(Connection conn, String sortColumn) throws SQLException { if (!ALLOWED_SORT_COLUMNS.contains(sortColumn)) { throw new IllegalArgumentException("عمود ترتيب غير صالح: " + sortColumn); } // sortColumn آمن — جاء من قائمتنا المسموحة، وليس من مدخل مستخدم خام String sql = "SELECT id, name, price, stock FROM products ORDER BY " + sortColumn; try (PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { List<Product> list = new ArrayList<>(); while (rs.next()) list.add(mapRow(rs)); return list; } }
لا تعتمد على فحص القائمة المسموحة وحده دون تعريف صارم. يجب أن تُدرج المجموعة معرّفات الأعمدة الدقيقة لا الأنماط. إذا بُنيت المجموعة من قيم enum تواجه المستخدم، فتحقق من أن كل قيمة enum تُعيَّن لاسم عمود حقيقي في فحص يجري مرة واحدة عند بدء التشغيل لا عند وقت الاستعلام.

الدفاع الثانوي: التحقق من صحة المدخلات

يُوقف التحديد بمعاملات الحقن؛ أما التحقق فيمنع وصول البيانات السيئة إلى قاعدة البيانات أصلًا. كلاهما مكمّل للآخر لا بديل عنه — طبّق الاثنين معًا.

تحقق مبكرًا عند الحدود التي تدخل منها المدخلات إلى نظامك (سيرفلت أو نقطة نهاية REST أو دالة خدمة). لكل حقل اطرح ثلاثة أسئلة:

  • النوع: هل هذا رقم / تاريخ / UUID فعلًا؟ حاول التحليل وارفض عند الإخفاق قبل لمس DAO.
  • النطاق / الطول: هل يُغذَّى عمود VARCHAR(150) بسلسلة من 10,000 حرف؟ ارفضها؛ لا تعتمد على قاعدة البيانات لاقتطاعها بصمت.
  • التنسيق: لعناوين البريد الإلكتروني وأرقام الهواتف وعناوين URL قواعد بنيوية. طبّقها بتعبير نمطي أو مكتبة مخصصة — لا تستخدم أبدًا جملة SQL LIKE.
// تحقق قبل لمس DAO public void createProduct(String name, String rawPrice, String rawStock) throws ValidationException, SQLException { if (name == null || name.isBlank()) { throw new ValidationException("اسم المنتج مطلوب."); } if (name.length() > 150) { throw new ValidationException("اسم المنتج يجب أن يكون 150 حرفًا أو أقل."); } double price; try { price = Double.parseDouble(rawPrice); } catch (NumberFormatException e) { throw new ValidationException("يجب أن يكون السعر رقمًا صالحًا."); } if (price < 0) { throw new ValidationException("لا يمكن أن يكون السعر سالبًا."); } int stock; try { stock = Integer.parseInt(rawStock); } catch (NumberFormatException e) { throw new ValidationException("يجب أن يكون المخزون عددًا صحيحًا."); } try (Connection conn = dataSource.getConnection()) { productDao.create(conn, name.strip(), price, stock); } }

حسابات قاعدة البيانات محدودة الصلاحيات

حتى الكود المُحدَّد تمامًا بمعاملات لا يستطيع منع الضرر إذا كان حساب قاعدة البيانات الخاص بالتطبيق يمتلك حقوقًا غير مقيّدة. اتّبع مبدأ أدنى الصلاحيات: امنح مستخدم التطبيق فقط الأذونات التي يحتاجها فعليًا.

-- MySQL: إنشاء مستخدم تطبيق مقيّد الصلاحيات CREATE USER 'shop_app'@'%' IDENTIFIED BY 'strong_random_password'; -- منح عمليات التطبيق فقط GRANT SELECT, INSERT, UPDATE, DELETE ON shop.products TO 'shop_app'@'%'; GRANT SELECT, INSERT, UPDATE, DELETE ON shop.orders TO 'shop_app'@'%'; -- لا GRANT OPTION، ولا حقوق DDL، ولا FILE، ولا SUPER FLUSH PRIVILEGES;

بهذا الإعداد، حتى لو استغل مهاجم خللًا منطقيًا ونفّذ SQL عشوائيًا من خلال حساب التطبيق، لا يستطيع تنفيذ DROP TABLE، ولا قراءة /etc/passwd عبر LOAD DATA INFILE، ولا الوصول إلى جداول خارج المجموعة المسموح بها.

استخدم حسابًا منفصلًا للقراءة فقط في مسارات تقتصر على SELECT. استعلامات التقارير ولوحات البيانات وعروض القراءة الإدارية لا تحتاج إلى حقوق INSERT أو UPDATE. حساب القراءة فقط الذي يُسرَّب لا يستطيع تعديل أي بيانات.

أفضل ممارسات إضافية

لا تخزّن كلمات المرور كنص عادي أبدًا. خزّن فقط هاش كلمة المرور المنتج بخوارزمية بطيئة ومُملَّحة. BCrypt من Spring Security أو java.security.MessageDigest مع KDF مناسب (PBKDF2) هما الخياران المعياريان. هاش SHA-256 الخام بدون ملح غير مقبول.

تجنب SELECT *. سمّ دائمًا الأعمدة التي تحتاجها. الاستعلامات ذات العلامة النجمية تسحب بيانات ربما لا تنوي الكشف عنها، وتتعطل عند تغيير ترتيب الأعمدة، وتجعل كود التعيين أصعب في المراجعة.

سجّل الاستعلامات دون المعاملات. إذا احتجت تسجيل SQL لأغراض التصحيح، سجّل القالب فقط — لا القيم المرتبطة أبدًا. سطر سجل يحتوي على WHERE password_hash = 'bcrypt$...' يُسرّب بيانات حساسة لكل من يملك صلاحية الوصول إلى السجلات.

استخدم قيود المخطط كخط دفاع أخير. قيود NOT NULL وUNIQUE وCHECK والمفاتيح الأجنبية تُمسك بمشاكل تكامل البيانات التي تفوت التحقق على مستوى التطبيق. ليست بديلًا عن التحقق على مستوى التطبيق بل دعامة موثوقة.

-- قيود المخطط تُكمّل التحقق على مستوى التطبيق CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(80) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_email_format CHECK (email LIKE '%@%.%') );

قائمة مراجعة: الوصول الآمن للبيانات

  • كل استعلام يلمس مدخلات المستخدم يستخدم PreparedStatement مع مُعيِّنات مكتوبة.
  • أسماء الأعمدة والجداول الديناميكية تُتحقق من صحتها بقائمة مسموحة صريحة قبل الدمج.
  • جميع استعلامات قوائم IN تبني عناصر ? النائبة برمجيًا وتربط كل قيمة منفردة.
  • التحقق من صحة المدخلات (النوع والطول والنطاق والتنسيق) يجري عند حدود الخدمة قبل أي استدعاء DAO.
  • حساب قاعدة البيانات الخاص بالتطبيق يحتفظ فقط بالصلاحيات الضرورية الدنيا.
  • كلمات المرور تُخزَّن كهاش، لا كنص عادي.
  • مخرجات السجل تحتوي على قوالب SQL، لا على قيم المعاملات المرتبطة.
  • قيود المخطط (NOT NULL وUNIQUE وCHECK) تدعم التحقق على مستوى التطبيق.

الخلاصة

يُمنع حقن SQL على المستوى المعماري باستخدام PreparedStatement لكل استعلام يتضمن بيانات خارجية. التحديد بمعاملات ليس خيارًا أسلوبيًا — بل هو الضمان الهيكلي بأن مدخلات المستخدم لا تستطيع أبدًا تغيير دلالات الاستعلام. أضف فوق ذلك: تحقق من المدخلات مبكرًا عند حدود الخدمة، طبّق حسابات قاعدة البيانات محدودة الصلاحيات، سمّ أعمدتك صراحةً، ودع قيود المخطط تعمل كدعامة. كل طبقة هشة منفردة؛ معًا تُشكّل استراتيجية وصول للبيانات متينة ومعمّقة في الدفاع تصمد أمام ظروف الهجوم الحقيقي.