Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 19: PDO và truy vấn tham số hóa trong PHP

PHP EP 19: PDO và truy vấn tham số hóa trong PHP

Cấu hình kết nối cơ sở dữ liệu bằng PDO trong PHP, thực hành truy vấn tham số hóa Prepared Statements để ngăn chặn SQL Injection và kiểm soát chế độ ném lỗi.

Nối dữ liệu vào SQL có thể làm đổi điều kiện lọc

Khi ứng dụng cần tìm thông tin người dùng hoặc lọc đơn hàng theo tham số từ trình duyệt, một thói quen nguy hiểm là ghép trực tiếp biến chuỗi vào câu lệnh SQL như "SELECT * FROM users WHERE email = '$email'". Nếu kẻ tấn công nhập chuỗi ' OR '1'='1 vào ô email, câu lệnh gửi tới hệ quản trị cơ sở dữ liệu sẽ biến thành SELECT * FROM users WHERE email = '' OR '1'='1'. Biểu thức '1'='1' luôn đúng với mọi dòng, khiến cơ sở dữ liệu trả về toàn bộ hồ sơ người dùng. Không dùng str_replace() làm cơ chế bảo vệ SQL; truy vấn tham số hóa giúp tách giá trị khỏi cấu trúc câu lệnh.

Bài học mở đầu Chặng 5 này hướng dẫn bạn kết nối cơ sở dữ liệu chuẩn mực bằng PDO (PHP Data Objects) kèm các lớp con chuyên biệt theo trình điều khiển (Pdo\Sqlite, Pdo\Mysql mới trong PHP 8.4), cấu hình chế độ ném ngoại lệ ERRMODE_EXCEPTION, tắt mô phỏng truy vấn ATTR_EMULATE_PREPARES => false và sử dụng Prepared Statements để ngăn dữ liệu tham số thay đổi cấu trúc SQL. Bài viết nối tiếp PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP và chuẩn bị cho PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP. Tài liệu tham chiếu gồm PHP Manual: PDO, Prepared statements và PHP RFC: Driver specific PDO subclasses.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) và SQLite 3.53.4 (pdo_sqlite) trên Alpine Linux 3.21 (aarch64).
  • Giới hạn kiểm chứng: Kiểm chứng kết nối PDO, Prepared Statements, chế độ nạp dữ liệu và lớp con Pdo\Sqlite của PHP 8.4 trên SQLite cục bộ; không tuyên bố kiểm chứng cân bằng tải cụm MySQL/PostgreSQL từ xa.

Tùy chọn PDO và giới hạn theo từng driver

Bảng dưới đây tổng hợp hợp đồng kỹ thuật của các cấu hình và phương thức quan trọng trong PDO:

Tùy chọn / Phương thức Cấu hình chuẩn trên PHP 8.4 Hành vi thực tế trong PDO Điều kiện / Không nên giả định
PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION (Mặc định PHP 8.0+) Mọi lỗi cú pháp SQL, mất kết nối hoặc vi phạm ràng buộc UNIQUE đều ném ngoại lệ PDOException. Không hiển thị trực tiếp $e->getMessage() của PDOException ra trình duyệt vì làm lộ cấu trúc bảng.
PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC Trả về mỗi dòng dưới dạng mảng kết hợp chỉ dùng tên cột làm khóa (không nhân đôi với khóa số như FETCH_BOTH). Tránh thêm khóa số; mức giảm bộ nhớ phụ thuộc dữ liệu, không cố định 50%.
PDO::ATTR_EMULATE_PREPARES false Yêu cầu prepared statements gốc ở driver hỗ trợ tùy chọn này. Tùy chọn phụ thuộc driver; SQLite dùng prepare gốc. Kiểu kết quả còn phụ thuộc driver và ATTR_STRINGIFY_FETCHES.
$pdo->prepare() + $stmt->execute() Dùng tham số đặt tên :email hoặc dấu hỏi ? Biên dịch cây cú pháp SQL trước, sau đó truyền giá trị tham số thuần túy vào vị trí giữ chỗ. Tham số giữ chỗ chỉ thay thế cho giá trị (value), không thể dùng làm tên bảng, tên cột hay ASC/DESC!
Lớp con chuyên biệt (PHP 8.4) Pdo\Sqlite::connect($dsn), Pdo\Mysql, Pdo\Pgsql Cung cấp các phương thức và hằng số riêng của từng hệ quản trị trên lớp con chính thức. PDO::connect() trong PHP 8.4 tự động trả về thực thể lớp con tương ứng với DSN.

Prepared statements tách dữ liệu khỏi cấu trúc SQL

Trong lab SQLite, prepare() tạo câu lệnh tham số hóa cục bộ. Với kết nối MySQL, cách chuẩn bị và thời điểm trao đổi với máy chủ phụ thuộc driver và chế độ giả lập. Theo PDO::setAttribute, không được suy rộng một thuộc tính cho mọi driver. Điều cần giữ là giá trị được bind vào vị trí dữ liệu, không được nối vào cấu trúc SQL.

Khi bước thứ hai $stmt->execute([':email' => "' OR '1'='1"]) diễn ra, chuỗi "' OR '1'='1" được bind như một giá trị, không trở thành toán tử của mệnh đề WHERE. Hệ quản trị CSDL chỉ đơn thuần đi tìm xem có bản ghi nào mang email đúng bằng chuỗi ' OR '1'='1 hay không và trả về false.

Từ PHP 8.4, đặc tả PHP RFC: Driver specific PDO subclasses bổ sung PDO::connect($dsn, ...) và các lớp con Pdo\Sqlite, Pdo\Mysql, Pdo\Pgsql giúp phân tách rõ các tính năng đặc thù của từng hệ quản trị.

Các bước khởi tạo PDO, chuẩn bị câu lệnh SQL (prepare) và gắn tham số thực thi.
Hình 1. Các bước khởi tạo PDO, chuẩn bị câu lệnh SQL (prepare) và gắn tham số thực thi. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Kết nối SQLite và kiểm tra truy vấn tham số hóa

Chúng ta sẽ khởi tạo cơ sở dữ liệu SQLite bằng PDO, tạo bảng users, chèn dữ liệu bằng Prepared Statement và thử tấn công bằng chuỗi ' OR '1'='1.

Bước 1: Tạo vùng làm việc tạm an toàn

Mục đích: Khởi tạo thư mục thực hành riêng trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep19-XXXXXX)"
cd "$WORKDIR"
pwd

Bước 2: Tạo kịch bản pdo_prepared.php thực thi truy vấn tham số hóa và chặn SQLi

Mục đích: Tạo bảng users, thêm 2 tài khoản, truy vấn tài khoản hợp lệ và kiểm tra kết quả khi truyền chuỗi SQL Injection vào tham số :email.

cat <<'EOF' > "$WORKDIR/pdo_prepared.php"
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite:' . __DIR__ . '/app.sqlite', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL)');
$ins = $pdo->prepare('INSERT INTO users (email, role) VALUES (:email, :role)');
$ins->execute([':email' => 'admin@thanhnh.id.vn', ':role' => 'admin']);
$ins->execute([':email' => 'staff@thanhnh.id.vn', ':role' => 'staff']);

$sel = $pdo->prepare('SELECT email, role FROM users WHERE email = :email');
$sel->execute([':email' => 'admin@thanhnh.id.vn']);
$validRow = $sel->fetch();

$sel->execute([':email' => "' OR '1'='1"]);
$injectedRow = $sel->fetch();

echo "Found={$validRow['role']} | Injected=" . ($injectedRow === false ? 'blocked' : 'leaked') . "\n";
EOF
php "$WORKDIR/pdo_prepared.php"

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

  • Đầu ra hiển thị: Found=admin | Injected=blocked.
  • Cùng một đối tượng $sel đã prepare một lần có thể gọi execute() lặp lại nhiều lần với các bộ tham số khác nhau và chặn đứng hoàn toàn chuỗi tấn công ' OR '1'='1.

Cảnh báo an toàn: Phương thức $stmt->fetch() trả về false khi không tìm thấy bản ghi nào. Hãy luôn kiểm tra if ($row === false) trước khi đọc $row['role'].

Dùng bindValue và lớp Pdo\Sqlite của PHP 8.4

Khi truyền mảng trực tiếp vào $stmt->execute([':lim' => $lim]), PDO mặc định coi mọi phần tử là kiểu chuỗi (PDO::PARAM_STR). Khi làm việc với LIMIT :lim hoặc cột số nguyên chặt chẽ, việc gọi $stmt->bindValue(':lim', $lim, PDO::PARAM_INT) giúp truyền đúng kiểu số nguyên.

Bước 3: Tạo kịch bản pdo_php84_subclass.php dùng Pdo\Sqlite::connect() và bindValue()

Mục đích: Khởi tạo kết nối qua lớp con Pdo\Sqlite mới của PHP 8.4 và dùng bindValue() với PDO::PARAM_INT.

cat <<'EOF' > "$WORKDIR/pdo_php84_subclass.php"
<?php
declare(strict_types=1);

$db = Pdo\Sqlite::connect('sqlite:' . __DIR__ . '/app.sqlite', null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $db->prepare('SELECT email FROM users ORDER BY id ASC LIMIT :lim');
$stmt->bindValue(':lim', 1, PDO::PARAM_INT);
$stmt->execute();

echo "Class=" . $db::class . " | FirstEmail=" . $stmt->fetchColumn() . "\n";
EOF
php "$WORKDIR/pdo_php84_subclass.php"

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

  • Màn hình in ra: Class=Pdo\Sqlite | FirstEmail=admin@thanhnh.id.vn. Phương thức $stmt->fetchColumn() đọc trực tiếp giá trị cột đầu tiên.

Cảnh báo an toàn: Vì tham số PDO không thể đại diện cho tên cột (ORDER BY :col), khi cho phép người dùng chọn cột sắp xếp, bạn bắt buộc phải đối chiếu qua danh sách trắng (allowlist) bằng biểu thức match ($inputSort).

Kiểm tra lỗi UNIQUE và dọn dẹp

Hãy kiểm chứng cách PDO::ERRMODE_EXCEPTION phản ứng khi chèn địa chỉ email bị trùng lặp vào cột UNIQUE.

Bước 4: Chèn bản ghi trùng email và kiểm tra ngoại lệ PDOException

Mục đích: Xác nhận PDO ném ngoại lệ PDOException ngay khi vi phạm ràng buộc toàn vẹn.

set +e
php -r '
$pdo = new PDO("sqlite:'"$WORKDIR"'/app.sqlite");
$stmt = $pdo->prepare("INSERT INTO users (email, role) VALUES (:e, :r)");
$stmt->execute([":e" => "admin@thanhnh.id.vn", ":r" => "guest"]);
'
RC=$?
set -e
echo "RC=$RC"

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

  • Tiến trình kết thúc với RC=255 kèm thông báo: Fatal error: Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email.
Khác biệt cơ chế giữa cộng chuỗi SQL trực tiếp và truyền tham số tách biệt qua PDO.
Hình 2. Khác biệt cơ chế giữa cộng chuỗi SQL trực tiếp và truyền tham số tách biệt qua PDO. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Cấu hình DSN và tạo tham số IN trên production

Khi kết nối MySQL/MariaDB hoặc PostgreSQL trên Production, hãy tuân thủ 2 quy tắc:

  1. Chỉ định charset=utf8mb4 trong DSN của MySQL: Khai báo mysql:host=...;dbname=...;charset=utf8mb4 để lưu trọn vẹn mọi ký tự Unicode 4 byte và đọc mật khẩu DB từ biến môi trường (getenv('DB_PASSWORD')).
  2. Xử lý mệnh đề WHERE id IN (?, ?, ?) động: Tạo chuỗi dấu hỏi bằng $placeholders = implode(',', array_fill(0, count($ids), '?')); rồi truyền array_values($ids) vào $stmt->execute().

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Vì sao truyền chuỗi ' OR '1'='1 vào tham số của Prepared Statement không thể làm thay đổi logic của mệnh đề WHERE? -> Vì tham số được xử lý như giá trị, không phải cú pháp SQL. Giá trị truyền ở execute() chỉ được coi là dữ liệu thuần túy.
  • Câu hỏi 2: Có thể dùng tham số Prepared Statement để truyền động tên bảng hoặc tên cột như ORDER BY :column không? -> Không. Tham số giữ chỗ chỉ thay thế cho giá trị dữ liệu; với tên cột sắp xếp phải dùng allowlist qua biểu thức match.
  • Câu hỏi 3: Thiết lập PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC có lợi gì so với PDO::FETCH_BOTH? -> Chỉ tạo khóa theo tên cột, tránh thêm khóa số của FETCH_BOTH; không suy ra tỷ lệ tiết kiệm bộ nhớ cố định.
  • Câu hỏi 4: Khi $stmt->fetch() không tìm thấy dòng nào khớp điều kiện, giá trị trả về của nó là null hay false? -> Trả về false. Phải kiểm tra if ($row === false) trước khi đọc các trường.

Hoàn thành bài học này, bạn đã nắm vững cách truy vấn an toàn với PDO. Hãy tiếp tục sang PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP để quản lý giao dịch và đóng gói tầng lưu trữ.

Ôn bài trước: PHP EP 18: Cookie, Session, CSRF và băm mật khẩu trong PHP. Học tiếp: PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP.

Bài liên quan trong bộ PHP

Tìm hiểu thêm