اولین بار که یک کوئری آسیب‌پذیر را در بازبینی امنیتی یکی از پروژه‌های مشتری دیدم، روزهای اول کار می‌کرد و هیچ خطایی هم نمی‌داد. مسئله اما این بود که رشته‌ای از ورودی کاربر مستقیماً به متن SQL چسبیده بود. همین یک خط، در عمل به معنای درِ بازِ دیتابیس برای هر کسی بود که کمی با SQL آشنا باشد. از آن روز، قبل از هر چیز در هر پروژه‌ای کوئری‌ها را با نگاه SQL Injection (تزریق اس‌کیوال) مرور می‌کنم و در نود و نه درصد مواقع، پاسخ همان یک تکنیک ساده است: Prepared Statements.

SQL Injection چیست و چرا هنوز خطرناک است؟

SQL Injection یا تزریق اس‌کیوال، یکی از قدیمی‌ترین و در عین حال پایدارترین حملات وب است. طبق اطلاعات ویکی‌پدیای فارسی درباره تزریق اس‌کیوال، این حمله وقتی رخ می‌دهد که ورودی کاربر بدون پردازش به بخشی از یک کوئری SQL تبدیل شود. چیزی که این حمله را از یک اشتباه ساده به یک فاجعه تبدیل می‌کند، پیامدهای آن است: خواندن کل دیتابیس، تغییر یا حذف داده‌ها، اجرای کوئری‌های دلخواه و در مواردی بالا بردن دسترسی تا سطح ادمین سرور دیتابیس.

اگر می‌خواهید ریشه این حمله را دقیق‌تر بشناسید، مقاله جلوگیری از SQL Injection را از همان ابتدا بخوانید. اما برای خلاصه: مسئله در جایی شکل می‌گیرد که کد شما بین داده و دستور تفاوت نمی‌گذارد. داده‌ای که کاربر وارد کرده، در نهایت چیزی جز یک رشته نیست؛ و اگر همان رشته به متن کوئری بچسبد، دیتابیس آن را به عنوان بخشی از دستور می‌فهمد.

کوئری، دستور است و داده فقط محتوا؛ لحظه‌ای که این دو را در یک رشته قاطی کنید، مرز امنیت را از دست داده‌اید.

Prepared Statements دقیقاً چیست و چطور کار می‌کند؟

Prepared Statements یا همان «دستورهای آماده‌شده»، تکنیکی است که در آن متن کوئری و داده کاربر در دو مرحله جدا از هم به دیتابیس فرستاده می‌شوند. در مرحله اول، ساختار کوئری با placeholder ارسال می‌شود؛ جایی که بعداً مقدار قرار می‌گیرد. در مرحله دوم، خود مقدار به عنوان پارامتر ارسال می‌شود. دیتابیس از قبل می‌داند کدام قسمت کوئری دستور است و کدام قسمت داده؛ پس هیچ راهی ندارد که مقدار ارسالی، رفتار دستور را عوض کند.

این مکانیزم در سطح پروتکل MySQL و MariaDB پشتیبانی می‌شود. یعنی وقتی با PDO یا MySQLi از placeholder استفاده می‌کنید، در واقع درخواست شما به دو بخش جدا تقسیم می‌شود و دیتابیس در سمت خودش پارامترها را جایگذاری می‌کند. به همین دلیل هم Prepared Statements در برابر SQL Injection مقاوم است، حتی اگر داده ارسالی شامل کاراکترهای خاصی مانند کوتیشن یا اسلش باشد.

چرا پارامترسازی، امنیت را تضمین می‌کند؟

پاسخ کوتاه این است که پارامتر، همیشه داده می‌ماند و هیچ‌وقت بخشی از دستور نمی‌شود. به زبان دقیق‌تر، دیتابیس هنگام آماده‌سازی کوئری، درخت دستوری آن را یک بار برای همیشه می‌سازد. بعد از آن، هر مقدار جدید صرفاً جایگزین یک محل مشخص در آن درخت می‌شود. هیچ مقدار داده‌ای نمی‌تواند ساختار آن درخت را تغییر دهد. این ویژگی باعث می‌شود حتی یک رشته با کاراکترهای مخرب، به عنوان بخشی از متن دستور تفسیر نشود.

یک نکته فنی که در پروژه‌های واقعی خیلی به کار می‌آید: برخلاف escape که یک عملیات در سمت زبان برنامه‌نویسی است و باید برای هر ورودی و هر دیتابیس جداگانه انجام شود، Prepared Statements در سمت موتور دیتابیس مدیریت می‌شود. همین باعث می‌شود احتمال اشتباه انسانی به شدت کاهش پیدا کند. برای مطالعه بیشتر درباره لایه‌های امنیتی PHP، نوشتن کد PHP امن برای وردپرس را ببینید.

چرا escape دستی کافی نیست؟

روش قدیمی و هنوز پرطرفدار در کدهای قدیمی PHP، استفاده از توابعی مثل mysqli_real_escape_string یا addslashes است. این توابع کار می‌کنند، اما مشکلات جدی دارند. اول این که باید در همه مسیرها و همه ورودی‌ها فراموش‌نشدنی اجرا شوند. یک بار فراموش‌کردن، کل سایت را در معرض حمله قرار می‌دهد. دوم این که این توابع به charset اتصال حساس هستند؛ اگر کاراکترست اتصال با کاراکترست فایل متفاوت باشد، escape می‌تواند به سادگی دور زده شود.

سوم، مهم‌ترین مشکل: escape فقط کوتیشن را می‌پوشاند. اگر ورودی کاربر جایی قرار بگیرد که کوتیشن آن معنا ندارد — مثلاً در نام ستون یا ORDER BY داینامیک — آن وقت escape هیچ محافظتی نمی‌کند و حمله با درج مقادیری مثل عدد یا نام ستون، انجام می‌شود. تمام این مشکلات با پارامترسازی از بین می‌روند چون مسئله نه در سطح رشته، که در سطح ساختار کوئری حل می‌شود.

escape مثل گذاشتن دستکش روی دست است؛ پارامترسازی مثل این است که دست‌هایتان را اصلاً به آتش نزدیک نکنید.

پیاده‌سازی با PDO در PHP

PDO (PHP Data Objects) یکی از توصیه‌شده‌ترین راه‌های اتصال PHP به دیتابیس است و از Prepared Statements هم به بهترین شکل پشتیبانی می‌کند. اگر با PDO آشنا نیستید، مقاله آموزش PDO در PHP و اتصال PHP به MySQL را بخوانید. یک نمونه ساده و امن از پیاده‌سازی:

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND status = :status');
$stmt->execute([
    ':email'  => $email,
    ':status' => $status,
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

در این کد، متن کوئری ثابت است و تنها مقادیر پارامتر تغییر می‌کنند. حتی اگر $email رشته‌ای شامل کاراکترهای مخرب باشد، دیتابیس آن را فقط به عنوان مقدار ایمیل می‌بیند. نکته مهم دیگر این است که در PDO باید PDO::ATTR_EMULATE_PREPARES را روی false بگذارید تا Prepared Statements واقعاً در سمت دیتابیس اجرا شوند و نه اینکه PDO آن‌ها را شبیه‌سازی کند. شبیه‌سازی، سطح امنیت را پایین می‌آورد و در شرایط خاص می‌تواند آسیب‌پذیر باشد.

پیاده‌سازی با MySQLi

MySQLi هم از Prepared Statements پشتیبانی می‌کند، هرچند رابطش کمی دست‌وپاگیرتر است. یک نمونه:

$stmt = $mysqli->prepare('SELECT id, name FROM products WHERE price < ? AND category = ?');
$stmt->bind_param('ds', $maxPrice, $category);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
    // پردازش ردیف
}
$stmt->close();

حروف ds در bind_param مشخص می‌کنند که اولین پارامتر double و دومی string است. هر اشتباهی در نوع پارامتر می‌تواند منجر به رفتار غیرمنتظره شود، به همین دلیل من در پروژه‌های تازه، PDO را ترجیح می‌دهم. PDO هم ساده‌تر است و هم قابلیت‌های مشترکی برای اتصال به دیتابیس‌های مختلف دارد.

Prepared Statements در وردپرس با wpdb

در وردپرس، شما معمولاً با دیتابیس از طریق کلاس wpdb کار می‌کنید که یک رابط اختصاصی برای کوئری‌های امن فراهم می‌کند. تابع $wpdb->prepare() دقیقاً همان مفهوم پارامترسازی را پیاده می‌کند. یک نمونه امن:

$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}postmeta WHERE meta_key = %s AND meta_value = %s",
        $key,
        $value
    )
);

نکته‌ای که در پروژه‌های واقعی زیاد دیدم: تابع prepare فقط زمانی امن است که ورودی مستقیماً در جای مناسب قرار بگیرد. اگر ورودی را در قسمتی از کوئری که با placeholder مطابقت ندارد بگذارید، نتیجه نه تنها امن نیست بلکه ممکن است خطای Fatal هم بدهد. به همین دلیل در وردپرس، لایه امنیتی نباید فقط به یک تابع سپرده شود؛ به همین اندازه مهم است که داده‌ها قبل از ورود به کوئری اعتبارسنجی و پاک‌سازی شده باشند.

اشتباهاتی که Prepared Statements را بی‌اثر می‌کند

دیدن Prepared Statements در کد، به تنهایی یعنی امنیت. مهم این است که درست استفاده شده باشد. رایج‌ترین اشتباهاتی که در پروژه‌ها دیده‌ام:

  • اتصال placeholder به متن کوئری: اگر پارامتر را داخل رشته کوئری به هم بچسبانید، امنیت از بین می‌رود. جایگذاری باید در سمت دیتابیس انجام شود، نه در سمت PHP.
  • ساخت نام جدول یا ستون از ورودی کاربر: پارامترسازی برای مقادیر است، نه شناسه‌ها. اگر نام جدول از ورودی کاربر بیاید، باید آن را در یک whitelist بررسی کنید.
  • استفاده از LIKE با ساخت دستی wildcard: اگر ورودی را با % ترکیب می‌کنید، همچنان پارامتر را جدا کنید و ترجیحاً از escape مناسب استفاده کنید.
  • نادیده‌گرفتن تراکنش‌ها: Prepared Statements از SQL Injection جلوگیری می‌کند ولی از Race Condition نه. برای عملیات پیچیده، تراکنش بگذارید.
  • ترکیب با کوئری‌های ساختگی: اگر در بخشی از کد از esc_sql ساده استفاده کرده باشید، همان نقطه می‌تواند شکننده باشد. تمام کوئری‌ها باید یکدست باشند.

پرسش‌های رایج درباره Prepared Statements و امنیت کوئری

آیا Prepared Statements روی همه دیتابیس‌ها کار می‌کند؟

بله، تمام دیتابیس‌های رابطه‌ای مدرن از جمله MySQL، MariaDB، PostgreSQL و SQL Server از پارامترسازی پشتیبانی می‌کنند. برای مطالعه بیشتر درباره لایه‌های امنیت دیتابیس، امنیت دیتابیس چیست و بهترین روش‌های امنیت MySQL را ببینید.

آیا Prepared Statements روی سرعت کوئری اثر منفی دارد؟

خیر، حتی در بعضی شرایط سرعت را بیشتر می‌کند. وقتی یک کوئری را چند بار با پارامترهای مختلف اجرا می‌کنید، دیتابیس از قبل می‌داند که ساختار آن ثابت است و می‌تواند برنامه اجرایی را کش کند.

آیا با ORM نیازی به Prepared Statements نیست؟

اکثر ORMهای مدرن مثل Doctrine و Eloquent در پشت صحنه از Prepared Statements استفاده می‌کنند. اما اگر ORM را دور بزنید و کوئری خام بنویسید، خودتان مسئول امنیت آن هستید.

آیا در وردپرس استفاده از wpdb->prepare کافی است؟

برای بخش زیادی از سناریوها بله، اما به شرطی که درست استفاده شود و با اعتبارسنجی و پاک‌سازی داده‌ها همراه باشد. برای سایر لایه‌ها، راهنمای امنیت وردپرس برای مبتدیان و کاربرد درست هوک‌های وردپرس را بخوانید.

نگاهی نهایی به امنیت کوئری‌ها

SQL Injection یکی از آن مشکلاتی است که وقتی رخ می‌دهد، جبران آن معمولاً هزینه‌ای چندبرابر پیشگیری دارد. Prepared Statements در واقع ساده‌ترین، مطمئن‌ترین و کم‌هزینه‌ترین راهی است که در دسترس هر توسعه‌دهنده PHP و وردپرس قرار دارد. تفاوت واقعی بین کد امن و کد ناامن، در ابزار نیست؛ در انسجامی است که در تمام کوئری‌های یک پروژه حفظ می‌شود. اگر تنها یک اصل از این مقاله را در پروژه بعدی‌تان اجرا کنید، همین کافی است: هیچ‌وقت داده را مستقیم داخل متن کوئری نگذارید؛ همیشه پارامتر را از دستور جدا کنید. تجربه‌های خودتان از مواجهه با کوئری‌های آسیب‌پذیر یا از بازنویسی آن‌ها با Prepared Statements را در دیدگاه‌ها بنویسید؛ تجربه‌های واقعی از هر آموزش کتابی برای خواننده بعدی مفیدتر است.