0

EzCode # Bài 8 — PDO và lớp Database

Học xong bài này bạn sẽ:

  • Kết nối PHP với MySQL bằng PDO và lấy được dữ liệu thật ra màn hình
  • Hiểu SQL Injection là gì và vì sao prepared statement chặn được nó
  • Viết trọn vẹn file Models/Database.php — nền móng của cả 6 Model

Cần học trước: Bài 6, Bài 7


1. Vấn đề đặt ra

Ở Bài 7 bạn đã nạp 14 khóa học vào database. Ở Bài 3 bạn đã biết cách in một mảng khóa học ra HTML.

Còn thiếu một mắt xích: làm sao lấy dữ liệu từ MySQL đưa vào biến PHP?

MySQL là một chương trình riêng, chạy độc lập với PHP. Hai bên nói chuyện với nhau qua một kết nối mạng, bằng ngôn ngữ SQL. PHP cần một "phiên dịch viên" để:

  1. Mở kết nối tới MySQL
  2. Gửi câu SQL sang
  3. Nhận kết quả về và biến thành mảng PHP
  4. Đóng kết nối khi xong

Phiên dịch viên đó tên là PDO.

Nhưng chỉ dùng PDO trần thì mỗi Model phải viết lại đoạn kết nối, đoạn try/catch, đoạn chuyển kết quả — mỗi lần khoảng 10 dòng, nhân với hơn 30 truy vấn trong project.

Vì vậy bài này làm hai việc: học PDO, rồi gói nó vào một class dùng chung tên Database. Sau bài này, mọi truy vấn trong EzCode chỉ còn tốn một dòng.


2. Kiến thức mới

2.1. PDO là gì

PDO viết tắt của PHP Data Objects. Nó là bộ thư viện có sẵn trong PHP để nói chuyện với cơ sở dữ liệu.

PHP có hai lựa chọn: mysqli và PDO. Vì sao chọn PDO?

mysqli PDO
Hỗ trợ hệ quản trị nào Chỉ MySQL 12 loại: MySQL, PostgreSQL, SQLite, SQL Server...
Cú pháp Có cả kiểu hàm lẫn kiểu object Chỉ kiểu object, thống nhất
Đặt tên cho tham số Không Có, dạng :ten
Báo lỗi bằng ngoại lệ Có, phải bật thêm

Ưu điểm lớn nhất của PDO: đổi từ MySQL sang PostgreSQL chỉ cần sửa một chuỗi kết nối, toàn bộ code truy vấn giữ nguyên.

2.2. Chuỗi DSN và kết nối

<?php
$conn = new PDO("mysql:host=localhost;dbname=ezcode", "root", "");
?>

Ba tham số:

  1. DSN (Data Source Name) — chuỗi mô tả kết nối, gồm: loại hệ quản trị (mysql), máy chủ (host=localhost), tên database (dbname=ezcode).
  2. Tên đăng nhập — với XAMPP mặc định là root.
  3. Mật khẩu — với XAMPP mặc định là chuỗi rỗng.

Cảnh báo cho sau này: tài khoản root không mật khẩu chỉ chấp nhận được trên máy của bạn. Website chạy thật ngoài internet phải có tài khoản riêng với mật khẩu mạnh và chỉ được cấp đúng quyền cần thiết.

2.3. trycatch — bắt lỗi

Kết nối database có thể thất bại: MySQL chưa chạy, sai mật khẩu, sai tên database. Nếu không xử lý, PHP sẽ dừng hẳn và người dùng thấy trang trắng.

<?php
try {
    $conn = new PDO("mysql:host=localhost;dbname=ezcode", "root", "");
    echo "Kết nối thành công";
} catch (PDOException $e) {
    echo "Kết nối thất bại: " . $e->getMessage();
}
?>

Cách hoạt động:

  • Code trong khối try được chạy thử.
  • Nếu có lỗi, PHP nhảy ngay sang khối catch, bỏ qua phần còn lại của try.
  • $e là object chứa thông tin lỗi. $e->getMessage() lấy ra dòng mô tả.
  • PDOException là loại lỗi riêng của PDO. Chỉ những lỗi loại này mới bị bắt ở đây.

Ví dụ thông báo bạn sẽ gặp:

Kết nối thất bại: SQLSTATE[HY000] [1049] Unknown database 'ezcode'

Đọc được ngay: database ezcode không tồn tại — bạn quên làm Bài 7.

2.4. ERRMODE_EXCEPTION — bắt PDO phải kêu lên

$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

Mặc định, khi một câu truy vấn sai cú pháp, PDO im lặng trả về false. Bạn không biết gì cả, chỉ thấy trang không có dữ liệu.

Dòng trên đổi hành vi đó: mọi lỗi đều ném ra PDOException, và khối catch của bạn sẽ bắt được.

Đây là dòng bắt buộc phải có. Không có nó, gỡ lỗi truy vấn là ác mộng.

2.5. SQL Injection — lỗ hổng nguy hiểm nhất

Đây là phần quan trọng nhất của bài.

Giả sử bạn viết truy vấn bằng cách nối chuỗi:

<?php
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
?>

Người dùng bình thường gõ hoang@gmail.com, câu SQL thành:

SELECT * FROM users WHERE email = 'hoang@gmail.com'

Đúng như mong đợi. Nhưng kẻ xấu gõ vào ô email chuỗi sau:

' OR '1'='1

Câu SQL biến thành:

SELECT * FROM users WHERE email = '' OR '1'='1'

Điều kiện '1'='1' luôn đúng, nên câu này trả về toàn bộ 35 người dùng. Nếu code lấy dòng đầu tiên và cho đăng nhập, kẻ xấu vừa chiếm được tài khoản mà không cần biết mật khẩu.

Tệ hơn nữa, có thể gõ:

'; DROP TABLE users; --

Câu SQL thành hai lệnh, lệnh thứ hai xóa sạch bảng users.

Nguyên nhân gốc: dữ liệu người dùng và câu lệnh SQL bị trộn chung thành một chuỗi. MySQL nhận được chuỗi đó và không có cách nào phân biệt đâu là lệnh của bạn, đâu là dữ liệu người ta gõ vào.

2.6. Prepared statement — cách chặn

Giải pháp là tách hẳn câu lệnh ra khỏi dữ liệu, gửi thành hai lần:

<?php
$sql  = "SELECT * FROM users WHERE email = ?";
$stmt = $conn->prepare($sql);
$stmt->execute([$email]);
?>

Quy trình:

  1. prepare() gửi câu SQL sang MySQL trước, với dấu ? ở chỗ cần điền. MySQL phân tích cấu trúc câu lệnh ngay lúc này và ghi nhớ nó.
  2. execute() gửi dữ liệu sang sau, dưới dạng một mảng riêng.
  3. MySQL điền dữ liệu vào chỗ dấu ?, nhưng cấu trúc câu lệnh đã cố định rồi — dữ liệu chỉ được coi là dữ liệu, không thể trở thành lệnh.

Bây giờ nếu kẻ xấu gõ ' OR '1'='1, MySQL sẽ đi tìm người dùng có email đúng bằng chuỗi ký tự ' OR '1'='1. Tất nhiên không có ai, và truy vấn trả về rỗng. Cuộc tấn công thất bại.

Dấu ? gọi là placeholder (chỗ giữ sẵn). Nhiều dấu ? thì thứ tự trong mảng phải khớp thứ tự xuất hiện:

<?php
$sql = "INSERT INTO users (name, email, password) VALUES (?, ?, ?)";
$stmt->execute([$name, $email, $hashedPassword]);
//               ^1     ^2      ^3
?>

Quy tắc tuyệt đối: không bao giờ nối dữ liệu người dùng vào chuỗi SQL. Luôn dùng ?.

Toàn bộ 6 Model của EzCode tuân thủ quy tắc này. Đây là điểm mạnh nhất về bảo mật của project.

một ngoại lệ bạn sẽ gặp ở Bài 12: ORDER BY không dùng được ?. Lúc đó phải dùng danh sách trắng bằng switch, và Bài 12 sẽ giải thích kỹ.

2.7. fetch()fetchAll()

Sau khi execute(), kết quả nằm trong object $stmt. Lấy ra bằng hai cách:

Hàm Trả về Dùng khi
fetch() Một dòng, dạng mảng kết hợp Lấy một khóa học theo id, một người dùng theo email
fetchAll() Mọi dòng, dạng mảng lồng nhau Lấy danh sách khóa học, danh sách học viên
<?php
// Lấy một dòng
$stmt = $conn->prepare("SELECT * FROM courses WHERE id = ?");
$stmt->execute([1]);
$course = $stmt->fetch();
echo $course['title'];              // Khóa học HTML CSS cơ bản

// Lấy nhiều dòng
$stmt = $conn->prepare("SELECT * FROM courses");
$stmt->execute();
$coursesList = $stmt->fetchAll();
foreach ($coursesList as $course) {
    echo $course['title'] . "<br>";
}
?>

Khi không tìm thấy gì:

  • fetch() trả về false
  • fetchAll() trả về mảng rỗng []

Vì vậy sau fetch() bạn nên kiểm tra if ($course), còn sau fetchAll() thì kiểm tra if (empty($coursesList)).

2.8. FETCH_ASSOC — chọn hình dạng kết quả

$stmt->setFetchMode(PDO::FETCH_ASSOC);

Mặc định PDO trả về mỗi dòng ở cả hai dạng cùng lúc: vừa có khóa là tên cột, vừa có khóa là số thứ tự. Một dòng 8 cột sẽ thành mảng 16 phần tử:

Array
(
    [id] => 1
    [0] => 1
    [title] => Khóa học HTML CSS cơ bản
    [1] => Khóa học HTML CSS cơ bản
    ...
)

Thừa và khó đọc. FETCH_ASSOC bỏ phần đánh số, chỉ giữ khóa là tên cột:

Array
(
    [id] => 1
    [title] => Khóa học HTML CSS cơ bản
    ...
)

Đúng bằng cấu trúc mảng kết hợp bạn học ở Bài 3, và đây là lý do code EzCode viết được $course['title'].

2.9. lastInsertId() — lấy id vừa tạo

Khi chèn một dòng mới, MySQL tự cấp id nhờ AUTO_INCREMENT (Bài 7, mục 2.4). Muốn biết số đó:

<?php
$stmt = $conn->prepare("INSERT INTO courses (title, price) VALUES (?, ?)");
$stmt->execute(["Khóa học mới", 250000]);

$idMoi = $conn->lastInsertId();      // ví dụ: 22
?>

Dùng để làm gì? Ví dụ tạo khóa học xong muốn chuyển thẳng người dùng tới trang sửa khóa học đó, bạn cần id.

Trong EzCode, giá trị này còn được dùng như tín hiệu thành công: Courses::create() trả về lastInsertId(), và TeacherController::createCourse() kiểm tra if ($result) — có id nghĩa là chèn thành công.

2.10. Tham số biến đổi ...$args

Đây là kỹ thuật làm cho class Database gọn được như nó sắp trở thành.

Vấn đề: các truy vấn cần số tham số khác nhau. Câu lấy khóa học theo id cần 1 tham số, câu tạo người dùng cần 5. Không thể viết một hàm cố định số tham số.

Toán tử ... khi khai báo hàm gom mọi đối số còn lại vào một mảng:

<?php
function thu($sql, ...$args)
{
    echo $sql;
    print_r($args);
}

thu("SELECT ...", 5);                    // $args = [5]
thu("INSERT ...", "Hoàng", "a@b.c", 10); // $args = ["Hoàng", "a@b.c", 10]
thu("SELECT ...");                       // $args = []
?>

Và vì execute() nhận vào một mảng, ta chuyển thẳng $args sang:

$stmt->execute($args);

Nhờ đó một hàm query() duy nhất phục vụ được mọi truy vấn, bất kể bao nhiêu tham số.

Toán tử ... có hai vai trò ngược nhau, tùy vị trí:

  • Khi khai báo hàmgom nhiều đối số thành mảng. Đó là trường hợp trên.
  • Khi gọi hàmtrải mảng thành nhiều đối số riêng lẻ. Bài 9 và Bài 12 sẽ dùng vai trò này.

Ví dụ vai trò thứ hai:

$params = ["Hoàng", "a@b.c"];
$db->query($sql, ...$params);      // giống hệt $db->query($sql, "Hoàng", "a@b.c")

3. Áp dụng vào EzCode

Bây giờ bạn viết file thật đầu tiên của project. Tạo thư mục Models ở gốc project nếu chưa có, rồi tạo file bên trong.

📁 Models/Database.phptạo mới

<?php
class Database
{
    private $servername = "localhost";
    private $username = "root";
    private $password = "";
    private $dbname = "ezcode";
    private $conn;

    function __construct()
    {
        try {
            $this->conn = new PDO("mysql:host=$this->servername;dbname=$this->dbname", $this->username, $this->password);
            // set the PDO error mode to exception
            $this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            // echo "Connected successfully";
        } catch (PDOException $e) {
            echo "Connection failed: " . $e->getMessage();
        }
    }
    function query($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            $stmt->execute($args);
            $result = $stmt->setFetchMode(PDO::FETCH_ASSOC);
            return $stmt->fetchAll();
        } catch (PDOException $e) {
            echo "Error: " . $e->getMessage();
        }
    }
    function queryOne($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            $stmt->execute($args);
            $result = $stmt->setFetchMode(PDO::FETCH_ASSOC);
            return $stmt->fetch();
        } catch (PDOException $e) {
            echo "Error: " . $e->getMessage();
        }
    }
    function insert($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            $stmt->execute($args);
            return $this->conn->lastInsertId();
        } catch (PDOException $e) {
            echo "Error: " . $e->getMessage();
        }
    }
    function update($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            return $stmt->execute($args);
        } catch (PDOException $e) {
            echo $sql . "<br>" . $e->getMessage();
        }
    }

    function delete($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            return $stmt->execute($args);
        } catch (PDOException $e) {
            echo $sql . "<br>" . $e->getMessage();
        }
    }

    function __destruct()
    {
        $this->conn = null;
    }
}

Đây là file quan trọng nhất của cả project. Mọi truy vấn trong EzCode đều đi qua một trong năm phương thức của nó. Ta đọc từng phần.

3.1. Năm thuộc tính

    private $servername = "localhost";
    private $username = "root";
    private $password = "";
    private $dbname = "ezcode";
    private $conn;

Bốn thuộc tính đầu là thông tin kết nối, được gán giá trị ngay khi khai báo. Thuộc tính thứ năm $conn chưa có giá trị — nó sẽ giữ object PDO sau khi constructor chạy.

Cả năm đều private, đúng nguyên tắc bạn học ở Bài 6: đây là chuyện nội bộ, bên ngoài không cần biết.

Chú ý $dbname = "ezcode" — phải khớp chính xác tên database bạn tạo ở Bài 7. Sai một chữ là không kết nối được.

3.2. Constructor

            $this->conn = new PDO("mysql:host=$this->servername;dbname=$this->dbname", $this->username, $this->password);

Chuỗi DSN được ghép bằng nội suy biến trong nháy kép, kỹ thuật bạn học ở Bài 2. Kết quả cuối cùng là mysql:host=localhost;dbname=ezcode.

Điều thú vị: PHP cho phép viết $this->servername thẳng trong chuỗi nháy kép mà không cần ngoặc nhọn. Với mảng thì phải có ngoặc, với thuộc tính object thì không bắt buộc.

Dòng // echo "Connected successfully"; bị tác giả ghi chú lại. Nếu bỏ ghi chú, mọi trang sẽ hiện dòng chữ đó ở đầu — hữu ích để kiểm tra một lần rồi tắt đi.

3.3. Năm phương thức truy vấn

Cả năm đều theo cùng một khuôn:

  1. prepare($sql) — gửi câu lệnh sang MySQL
  2. execute($args) — gửi dữ liệu sang
  3. Trả kết quả về
  4. catch in lỗi ra nếu có

Khác nhau chỉ ở bước 3:

Phương thức Trả về Dùng cho
query() fetchAll() — mảng nhiều dòng SELECT nhiều kết quả
queryOne() fetch() — một dòng SELECT một kết quả
insert() lastInsertId() — id vừa tạo INSERT
update() execute()true hoặc false UPDATE
delete() execute()true hoặc false DELETE

Thực ra update()delete() giống hệt nhau từng chữ. Tác giả tách thành hai tên để code gọi đọc rõ nghĩa hơn: $this->db->delete($sql, $id) nói lên ý định tốt hơn $this->db->update($sql, $id).

Một dòng thừa cần chú ý:

$result = $stmt->setFetchMode(PDO::FETCH_ASSOC);

Biến $result được gán nhưng không bao giờ dùng tới. setFetchMode() chỉ có tác dụng phụ là đặt chế độ, giá trị trả về của nó vô nghĩa. Viết $stmt->setFetchMode(PDO::FETCH_ASSOC); là đủ.

Không gây lỗi, chỉ là code thừa. Bạn cứ gõ y hệt để khớp repo.

3.4. Destructor

    function __destruct()
    {
        $this->conn = null;
    }

Gán null cho $conn là cách PDO đóng kết nối. Chạy tự động khi trang kết thúc.

Thực ra PHP tự đóng mọi kết nối khi script chạy xong, nên dòng này không bắt buộc. Nhưng viết ra thì rõ ràng hơn, và nếu sau này bạn muốn thêm việc dọn dẹp khác thì đã có sẵn chỗ.

⚠️ Lưu ý: Cả năm khối catch chỉ in thông báo lỗi ra giữa trang rồi kết thúc mà không return gì cả — nghĩa là hàm trả về null.

Hai hậu quả:

  1. Hàm gọi không biết đã có lỗi. Nó nhận null và thường không kiểm tra, dẫn tới lỗi tiếp theo ở chỗ khác — xa chỗ gây lỗi, rất khó lần ra.
  2. Thông báo lỗi lộ ra cho người dùng. Nội dung PDOException thường chứa cả câu SQL và tên cột. Với update()delete() thì tác giả echo $sql ra hẳn hoi. Kẻ xấu rất thích thông tin này.

Cách làm đúng là ghi lỗi vào file log rồi ném ngoại lệ lên cho tầng trên xử lý. Vấn đề này được bàn ở mục ghi chú cuối Bài 22, cùng với chuyện thiếu kiểm tra null.


4. Chạy thử

Tạo hoc/bai08.php. Chú ý đường dẫn include_once phải lùi ra một cấp bằng ../ vì file này nằm trong thư mục hoc/.

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

include_once "../Models/Database.php";

$db = new Database();

echo "<h2>Thử nghiệm 1 — lấy nhiều dòng bằng query()</h2>";
$coursesList = $db->query("SELECT * FROM courses LIMIT 3");
echo "<pre>";
print_r($coursesList);
echo "</pre>";

echo "<h2>Thử nghiệm 2 — lấy một dòng bằng queryOne()</h2>";
$course = $db->queryOne("SELECT * FROM courses WHERE id = ?", 1);
echo "<p>Tên khóa: " . $course['title'] . "</p>";
echo "<p>Giá: " . number_format($course['price']) . "</p>";

echo "<h2>Thử nghiệm 3 — dùng foreach in ra danh sách</h2>";
$tatCa = $db->query("SELECT * FROM courses WHERE status = 1 ORDER BY id DESC");
echo "<p>Tìm thấy " . count($tatCa) . " khóa học.</p>";
foreach ($tatCa as $c) {
    echo "<p>" . $c['id'] . " — " . $c['title'] . " — " . number_format($c['price']) . "</p>";
}

echo "<h2>Thử nghiệm 4 — truyền tham số</h2>";
$dat = $db->query("SELECT * FROM courses WHERE price > ?", 400000);
echo "<p>Có " . count($dat) . " khóa học giá trên 400.000</p>";
?>

Mở http://localhost/EZCode_khoahoc/hoc/bai08.php.

Kết quả đúng:

  • Thử nghiệm 1 in ra mảng lồng nhau gồm 3 khóa học, mỗi khóa là mảng kết hợp có 8 khóa đúng bằng 8 tên cột của bảng courses.
  • Thử nghiệm 2 in ra Khóa học HTML CSS cơ bản300,000.
  • Thử nghiệm 3 in ra 14 dòng, khóa id = 21 ở đầu vì sắp xếp giảm dần.
  • Thử nghiệm 4 in ra Có 7 khóa học giá trên 400.000 — khớp với kết quả bài tập 1 của Bài 7.

Hãy dừng lại nhìn kỹ kết quả của thử nghiệm 1. Đây là khoảnh khắc quan trọng: bạn vừa thấy dữ liệu thật từ MySQL biến thành đúng cấu trúc mảng lồng nhau mà bạn đã tự gõ tay ở Bài 3. Mắt xích cuối cùng đã nối xong.

Nếu gặp lỗi:

Thông báo Nguyên nhân Cách sửa
Connection failed: SQLSTATE[HY000] [1049] Unknown database 'ezcode' Chưa tạo database Làm lại Bài 7 bước 2
Connection failed: SQLSTATE[HY000] [2002] MySQL chưa chạy Bật MySQL trong XAMPP
Failed opening '../Models/Database.php' Sai đường dẫn hoặc chưa tạo file Kiểm tra file nằm đúng Models/Database.php
Error: SQLSTATE[42S02] Base table ... doesn't exist Database rỗng Làm lại Bài 7 bước 3

5. Bài tập

Bài tập 1

Trong hoc/bai08_bt1.php, dùng queryOne() lấy khóa học có id = 2, rồi in ra:

  • Tên khóa học
  • Giá đã định dạng kiểu Việt Nam (có dấu chấm và chữ đ)
  • Mô tả

Sau đó thử đổi thành id = 999 và mô tả điều gì xảy ra. Sửa code để trường hợp này in ra "Không tìm thấy khóa học" thay vì báo lỗi.

Bài tập 2

Trong hoc/bai08_bt2.php, viết một hàm layKhoaHocRe($db, $giaToiDa) dùng query() với tham số ? để lấy mọi khóa học có giá nhỏ hơn $giaToiDa, rồi in ra dưới dạng bảng HTML.

Gọi hàm ba lần với ba mức giá: 100.000, 350.000, 600.000. Mỗi lần in kèm dòng "Tìm thấy N khóa học".

Bài tập 3

Đây là bài tập quan trọng nhất — bạn sẽ tự tay tấn công rồi tự vá.

Tạo hoc/bai08_bt3.php. Vì bài này cần chạy SQL nối chuỗi mà class Database không cho phép, hãy tự tạo kết nối PDO trực tiếp trong file.

Phần A — Tấn công. Viết truy vấn nối chuỗi trực tiếp:

$email = "' OR '1'='1";
$sql = "SELECT * FROM users WHERE email = '" . $email . "'";

Chạy nó bằng $conn->query($sql) và đếm số dòng trả về. Ghi lại con số.

Phần B — Vá. Viết lại cùng truy vấn đó bằng prepared statement với dấu ?, truyền vào đúng chuỗi ' OR '1'='1. Đếm số dòng trả về.

Phần C — Giải thích. Viết một đoạn trả lời: vì sao hai cách cho ra kết quả khác nhau, và điều gì xảy ra ở phía MySQL trong mỗi trường hợp.


6. Đáp án

Đáp án bài tập 1

📄 hoc/bai08_bt1.php

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

include_once "../Models/Database.php";

$db = new Database();
$id = 2;

$course = $db->queryOne("SELECT * FROM courses WHERE id = ?", $id);

if ($course) {
    echo "<h3>" . $course['title'] . "</h3>";
    echo "<p>Giá: " . number_format($course['price'], 0, ',', '.') . "đ</p>";
    echo "<p>Mô tả: " . $course['description'] . "</p>";
} else {
    echo "<p>Không tìm thấy khóa học</p>";
}
?>

Điều xảy ra với id = 999: queryOne() gọi fetch(), và fetch() trả về false khi không có dòng nào. Nếu bạn viết thẳng echo $course['title']; mà không kiểm tra, PHP báo:

Warning: Trying to access array offset on value of type bool

Câu if ($course) chặn được điều đó, vì false được coi là "sai" trong điều kiện.

Đây chính là điểm yếu của EzCode. Mở Controllers/CourseController.php và nhìn phương thức detail($id): nó gọi getById($id) rồi nạp thẳng View mà không hề kiểm tra kết quả có false không.

Bạn có thể tự kiểm chứng sau khi làm Bài 13: mở ?ctrl=course&act=detail&id=999 và xem trang vỡ ra sao. Vấn đề này nằm ở mục ghi chú cuối Bài 22.

Đáp án bài tập 2

📄 hoc/bai08_bt2.php

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

include_once "../Models/Database.php";

$db = new Database();

function layKhoaHocRe($db, $giaToiDa)
{
    return $db->query(
        "SELECT * FROM courses WHERE price < ? AND status = 1 ORDER BY price ASC",
        $giaToiDa
    );
}

function inBang($ketQua, $giaToiDa)
{
    echo "<h3>Khóa học dưới " . number_format($giaToiDa) . "</h3>";
    echo "<p>Tìm thấy " . count($ketQua) . " khóa học.</p>";

    if (empty($ketQua)) {
        echo "<p>Không có khóa học nào.</p>";
        return;
    }

    echo "<table border='1' cellpadding='8'>";
    echo "<tr><th>ID</th><th>Tên khóa học</th><th>Giá</th></tr>";
    foreach ($ketQua as $course) {
        echo "<tr>";
        echo "<td>" . $course['id'] . "</td>";
        echo "<td>" . $course['title'] . "</td>";
        echo "<td>" . number_format($course['price']) . "</td>";
        echo "</tr>";
    }
    echo "</table>";
}

inBang(layKhoaHocRe($db, 100000), 100000);
inBang(layKhoaHocRe($db, 350000), 350000);
inBang(layKhoaHocRe($db, 600000), 600000);
?>

Kết quả: mức 100.000 tìm được 2 khóa (giá 30.000 và 25.000), mức 350.000 tìm được 6 khóa, mức 600.000 tìm được 10 khóa.

Chú ý hàm layKhoaHocRe() nhận $db làm tham số. Vì $db là biến toàn cục nằm ngoài hàm, PHP không tự nhìn thấy nó bên trong hàm — bạn phải truyền vào. Đây là lý do lập trình hướng đối tượng tiện hơn: nếu layKhoaHocRe() là phương thức của class thì nó dùng $this->db được ngay, không cần truyền.

Đó chính xác là điều Models/Courses.php làm, và bạn sẽ viết nó ở Bài 10.

Đáp án bài tập 3

📄 hoc/bai08_bt3.php

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

$conn = new PDO("mysql:host=localhost;dbname=ezcode", "root", "");
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$emailDocHai = "' OR '1'='1";

echo "<h2>Phần A — Nối chuỗi trực tiếp (NGUY HIỂM)</h2>";

$sqlXau = "SELECT * FROM users WHERE email = '" . $emailDocHai . "'";
echo "<p>Câu SQL thực sự gửi đi:</p>";
echo "<pre>" . htmlspecialchars($sqlXau) . "</pre>";

$ketQuaXau = $conn->query($sqlXau)->fetchAll(PDO::FETCH_ASSOC);
echo "<p><strong>Số dòng trả về: " . count($ketQuaXau) . "</strong></p>";
if (!empty($ketQuaXau)) {
    echo "<p>Người dùng đầu tiên bị lộ: " . $ketQuaXau[0]['email'] . "</p>";
}

echo "<hr><h2>Phần B — Prepared statement (AN TOÀN)</h2>";

$sqlTot = "SELECT * FROM users WHERE email = ?";
echo "<p>Câu SQL gửi đi:</p>";
echo "<pre>" . htmlspecialchars($sqlTot) . "</pre>";
echo "<p>Dữ liệu gửi riêng: " . htmlspecialchars($emailDocHai) . "</p>";

$stmt = $conn->prepare($sqlTot);
$stmt->execute([$emailDocHai]);
$ketQuaTot = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "<p><strong>Số dòng trả về: " . count($ketQuaTot) . "</strong></p>";
?>

Kết quả quan sát được:

  • Phần A trả về 35 dòng — toàn bộ người dùng trong database.
  • Phần B trả về 0 dòng.

Phần C — Giải thích:

Ở phần A, PHP ghép chuỗi trước rồi mới gửi sang MySQL. Thứ MySQL nhận được là:

SELECT * FROM users WHERE email = '' OR '1'='1'

MySQL phân tích câu này và thấy một biểu thức OR gồm hai vế: email = '''1'='1'. Vế thứ hai luôn đúng, nên OR luôn đúng với mọi dòng, và toàn bộ bảng được trả về.

MySQL hoàn toàn không có lỗi ở đây. Nó nhận được một câu lệnh hợp lệ và thực hiện đúng. Vấn đề là PHP đã để dữ liệu người dùng biến thành cấu trúc câu lệnh — hai dấu nháy đơn trong chuỗi độc hại đã "thoát ra" khỏi cặp nháy bao quanh và trở thành cú pháp SQL.

Ở phần B, có hai lần gửi tách biệt:

  1. Lần một, MySQL nhận câu SELECT * FROM users WHERE email = ?. Nó phân tích ngay và khóa cứng cấu trúc: đây là một câu SELECT, có một điều kiện so sánh cột email với một giá trị chưa biết.
  2. Lần hai, MySQL nhận chuỗi ' OR '1'='1 như một giá trị. Cấu trúc đã cố định rồi, không có cách nào chuỗi này trở thành cú pháp nữa.

MySQL đi tìm người dùng có email đúng bằng 10 ký tự ' OR '1'='1. Không ai có email như vậy, nên kết quả rỗng.

Ẩn dụ để nhớ: nối chuỗi giống như đọc chính tả cho thư ký — nếu bạn đọc "gửi thư cho An, chấm hết, và xóa hết hồ sơ", thư ký không phân biệt được đâu là nội dung đâu là mệnh lệnh. Prepared statement giống như đưa thư ký một biểu mẫu đã in sẵn có ô trống — dù bạn viết gì vào ô, nó vẫn chỉ là nội dung của ô đó.

Đối chiếu với EzCode: mở bất kỳ Model nào và bạn sẽ thấy mọi truy vấn đều dùng ?. Ví dụ Models/User.php:

$sql = "SELECT * FROM users WHERE email=?";
return $this->db->queryOne($sql, $email);

Đây là điểm project làm rất tốt. So sánh với các lỗ hổng bạn sẽ tìm ở Bài 22 — XSS, thiếu kiểm tra quyền, lộ khóa bí mật — thì SQL Injection là loại tấn công duy nhất mà EzCode phòng thủ đầy đủ.


➡️ Bài tiếp theo: Bài 9 — MVC và Front Controller

⬅️ Về mục lục · Bài trước


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí