การป้องกัน SQL Injection: การใช้งาน Prepared Statements อย่างถูกต้อง
ในบทเรียนก่อนหน้านี้ เราได้เรียนรู้เกี่ยวกับการทำความสะอาดข้อมูล (Data Sanitization) เพื่อป้องกันข้อมูลที่ไม่พึงประสงค์เข้าสู่ระบบของเรา แต่การทำความสะอาดข้อมูลเพียงอย่างเดียวอาจไม่เพียงพอที่จะป้องกันการโจมตีที่เรียกว่า SQL Injection ได้อย่างสมบูรณ์แบบ ในบทนี้ เราจะมาเจาะลึกถึงวิธีการป้องกัน SQL Injection อย่างมีประสิทธิภาพด้วยการใช้งาน Prepared Statements อย่างถูกต้อง
SQL Injection คืออะไร?
SQL Injection คือช่องโหว่ด้านความปลอดภัยที่เกิดขึ้นเมื่อผู้ไม่หวังดีสามารถแทรกคำสั่ง SQL ที่เป็นอันตรายเข้าไปใน query ที่แอปพลิเคชันของคุณส่งไปยังฐานข้อมูล ซึ่งอาจนำไปสู่การเข้าถึงข้อมูลที่ไม่ได้รับอนุญาต การแก้ไข หรือแม้กระทั่งการลบข้อมูลในฐานข้อมูลของคุณ
ตัวอย่างเช่น หากคุณมี form ที่ให้ผู้ใช้กรอกชื่อผู้ใช้ และนำข้อมูลนั้นไปใช้ใน query ดังนี้:
$username = $_POST['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = $conn->query($sql);
หากผู้ใช้กรอกชื่อผู้ใช้เป็น ' OR '1'='1, query จะกลายเป็น:
SELECT * FROM users WHERE username = '' OR '1'='1'
เนื่องจาก '1'='1' เป็นจริงเสมอ, query นี้จะดึงข้อมูลผู้ใช้ทั้งหมดในฐานข้อมูล ซึ่งเป็นการละเมิดความปลอดภัยอย่างร้ายแรง
Prepared Statements: เกราะป้องกัน SQL Injection
Prepared Statements เป็นวิธีที่ปลอดภัยกว่าในการสร้าง query SQL เนื่องจากแยกส่วนของ query ที่เป็นโครงสร้าง (template) ออกจากข้อมูลที่ผู้ใช้ป้อน (data) ทำให้ฐานข้อมูลสามารถประมวลผลโครงสร้างของ query ล่วงหน้า และ treat ข้อมูลที่ผู้ใช้ป้อนเป็นเพียงข้อมูล ไม่ใช่ส่วนหนึ่งของคำสั่ง SQL
Prepared Statements ทำงานโดย:
- เตรียม (Prepare) query: ส่งโครงสร้างของ query ไปยังฐานข้อมูล โดยมี placeholder สำหรับข้อมูลที่ผู้ใช้ป้อน
- Bind parameters: ผูกข้อมูลที่ผู้ใช้ป้อนกับ placeholder
- Execute: สั่งให้ฐานข้อมูลรัน query
ด้วยวิธีนี้ ฐานข้อมูลจะรู้ว่าส่วนใดของ query คือโครงสร้าง และส่วนใดคือข้อมูล ทำให้ไม่สามารถตีความข้อมูลที่ผู้ใช้ป้อนเป็นคำสั่ง SQL ได้
การใช้งาน Prepared Statements อย่างถูกต้อง
ในการใช้งาน Prepared Statements อย่างถูกต้อง คุณต้องหลีกเลี่ยงการใช้ string concatenation เพื่อสร้าง query และใช้ฟังก์ชัน `bindParam()` หรือ `bindValue()` เพื่อผูกข้อมูลกับ placeholder
การหลีกเลี่ยงการใช้ String Concatenation
การใช้ string concatenation เพื่อสร้าง query จะทำให้คุณเสี่ยงต่อ SQL Injection แม้ว่าคุณจะพยายามทำความสะอาดข้อมูลแล้วก็ตาม ตัวอย่างที่ไม่ควรทำ:
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '" . $username . "' AND password = '" . $password . "'"; // ไม่ปลอดภัย!
$result = $conn->query($sql);
ตัวอย่างนี้ยังคงเปิดช่องโหว่ให้ผู้ไม่หวังดีสามารถแทรกคำสั่ง SQL ที่เป็นอันตรายเข้าไปใน query ได้
การใช้ `bindParam()` หรือ `bindValue()`
`bindParam()` และ `bindValue()` เป็นฟังก์ชันที่ใช้ในการผูกข้อมูลกับ placeholder ใน Prepared Statements ทั้งสองฟังก์ชันมีความคล้ายคลึงกัน แต่มีความแตกต่างที่สำคัญ:
- `bindParam()`: ผูกตัวแปรกับ placeholder โดยการอ้างอิง (reference) หากค่าของตัวแปรเปลี่ยนแปลงหลังจากผูกแล้ว ค่าใน query ก็จะเปลี่ยนแปลงตาม
- `bindValue()`: ผูกค่าของตัวแปรกับ placeholder โดยการคัดลอก (copy) หากค่าของตัวแปรเปลี่ยนแปลงหลังจากผูกแล้ว ค่าใน query จะไม่เปลี่ยนแปลง
สำหรับกรณีส่วนใหญ่ `bindValue()` เป็นตัวเลือกที่ปลอดภัยกว่า เนื่องจากป้องกันการเปลี่ยนแปลงค่าโดยไม่ได้ตั้งใจ
ตัวอย่างการใช้งาน `bindValue()`:
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->bindValue(':username', $username, PDO::PARAM_STR);
$stmt->bindValue(':password', $password, PDO::PARAM_STR); // ควร hash password ก่อน!
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
ในตัวอย่างนี้ เราได้ใช้ `bindValue()` เพื่อผูกค่าของ `$username` และ `$password` กับ placeholder `:username` และ `:password` โดยระบุประเภทของข้อมูลเป็น `PDO::PARAM_STR` (string) การทำเช่นนี้จะทำให้ฐานข้อมูล treat ค่าเหล่านี้เป็นข้อมูล ไม่ใช่ส่วนหนึ่งของคำสั่ง SQL
ตัวอย่างการใช้งาน `bindParam()`:
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->bindParam(':username', $username, PDO::PARAM_STR);
$stmt->bindParam(':password', $password, PDO::PARAM_STR); // ควร hash password ก่อน!
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
การใช้งาน `bindParam()` ในลักษณะนี้ให้ผลลัพธ์เหมือนกับ `bindValue()` แต่หากคุณมีการเปลี่ยนแปลงค่าของ `$username` หรือ `$password` หลังจากเรียก `bindParam()` ค่าใน query ก็จะเปลี่ยนแปลงตาม
การตรวจสอบ SQL Query
แม้ว่าคุณจะใช้ Prepared Statements แล้ว ก็ควรตรวจสอบ SQL query ที่ถูกสร้างขึ้นเสมอ เพื่อให้แน่ใจว่าไม่มีอะไรผิดพลาดเกิดขึ้น คุณสามารถใช้ฟังก์ชัน `debugDumpParams()` เพื่อดู query และ parameters ที่ถูกส่งไปยังฐานข้อมูล:
$username = $_POST['username'];
$password = $_POST['password'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->bindValue(':username', $username, PDO::PARAM_STR);
$stmt->bindValue(':password', $password, PDO::PARAM_STR); // ควร hash password ก่อน!
$stmt->execute();
$stmt->debugDumpParams(); // แสดง query และ parameters
$user = $stmt->fetch(PDO::FETCH_ASSOC);
ฟังก์ชันนี้จะแสดงข้อมูลที่เป็นประโยชน์สำหรับการ debugging เช่น:
SQL: [92] SELECT * FROM users WHERE username = :username AND password = :password
Params: 2
Key: Name: [8] :username
paramno=0
name=[8] ":username"
is_param=1
param_type=2
Key: Name: [8] :password
paramno=1
name=[8] ":password"
is_param=1
param_type=2
จากข้อมูลนี้ คุณสามารถตรวจสอบได้ว่า query ถูกสร้างขึ้นอย่างถูกต้อง และ parameters ถูกผูกกับ placeholder อย่างถูกต้อง
สรุป
การป้องกัน SQL Injection เป็นสิ่งสำคัญอย่างยิ่งในการพัฒนาเว็บแอปพลิเคชัน การใช้งาน Prepared Statements อย่างถูกต้อง โดยหลีกเลี่ยงการใช้ string concatenation และใช้ `bindParam()` หรือ `bindValue()` จะช่วยปกป้องฐานข้อมูลของคุณจากการโจมตีที่อาจเกิดขึ้น นอกจากนี้ การตรวจสอบ SQL query ที่ถูกสร้างขึ้นยังเป็นขั้นตอนสำคัญในการตรวจสอบความถูกต้องและความปลอดภัยของโค้ดของคุณ
ในบทเรียนถัดไป เราจะมาเรียนรู้เกี่ยวกับการป้องกัน CSRF (Cross-Site Request Forgery) ซึ่งเป็นอีกหนึ่งช่องโหว่ด้านความปลอดภัยที่พบบ่อยในเว็บแอปพลิเคชัน