Bài 5 — A05:2025 Injection: tách dữ liệu khỏi lệnh — SQL, OS command, XSS, code injection

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 4 — A04 Cryptographic Failures · Bài 5 — A05 Injection (bài này) · Tiếp: Bài 6 — A06 Insecure Design →

Injection (chèn lệnh — dữ liệu không tin cậy bị một interpreter hiểu thành một phần của lệnh) là mục "già" nhất của Top 10 và cũng là mục mà ai cũng nghĩ mình đã biết: "dùng ORM là xong". Nhưng OWASP 2025 vẫn ghi nhận 100% ứng dụng được test có kiểm tra một dạng injection, và đây là mục có nhiều CVE nhất trong cả danh sách (62.445). Lý do không nằm ở chỗ dev không biết prepared statement, mà ở những chỗ ORM không phủ: cột sắp xếp do người dùng chọn, report builder ghép điều kiện động, một lệnh whereRaw "tạm thời", một {!! !!} để hiện nội dung soạn bằng editor, một lệnh gọi ffmpeg qua shell. Câu hỏi interviewer muốn nghe ở mức Senior/Tech Lead là: em giữ ranh giới "đâu là lệnh, đâu là dữ liệu" ở từng interpreter bằng cơ chế gì, chỗ nào framework không giữ hộ, và em phát hiện chỗ đó bằng cách nào trước khi lên production.

Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP, docs và mã nguồn Laravel, docs TypeORM / Prisma / Node.js / React, mã nguồn Moodle) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở cuối bài. Riêng hành vi "Laravel + pdo_pgsql không giả lập prepare" đã có lab thật trong bài Khi plan sai của KB và được dẫn lại. Trọng tâm là phòng thủ: mỗi cơ chế được giải thích ở mức "giả định nào trong code bị sai và hệ quả là gì"; bài không chứa chuỗi tấn công hay hướng dẫn khai thác.

1. Mục này là gì trong 2025

1.1 Định nghĩa của OWASP và bốn dấu hiệu dễ tổn thương

1. OWASP định nghĩa: lỗ hổng injection là "an application flaw that allows untrusted user input to be sent to an interpreter (e.g. a browser, database, the command line) and causes the interpreter to execute parts of that input as commands" — dữ liệu không tin cậy được gửi tới một interpreter (trình diễn giải: trình duyệt, database, shell) và interpreter thực thi một phần dữ liệu đó như lệnh. Từ khoá quan trọng là interpreter: injection không phải lỗi của một ngôn ngữ, mà là lỗi ở ranh giới giữa code của mình và một bộ phân tích cú pháp khác (SQL parser, HTML parser, /bin/sh, template engine, JNDI lookup...).

2. OWASP liệt kê bốn tình huống khiến ứng dụng dễ tổn thương. Em diễn giải và gắn mỗi tình huống với một dạng code hay gặp:

Dấu hiệu (diễn giải từ OWASP)Dạng code hay gặp trong Laravel / NestJS
Dữ liệu người dùng không được validate, lọc hay làm sạchĐưa thẳng $request->input('sort') hay req.query.sort vào query, không qua Form Request / DTO
Query động hoặc lời gọi không tham số hoá, không escape theo ngữ cảnh, được đưa thẳng vào interpreterDB::select("... '$x'"), query(`... ${x}`), exec("cmd " + x)
Dữ liệu chưa làm sạch được dùng trong tham số tìm kiếm của ORM để lấy thêm bản ghi nhạy cảmTruyền nguyên object filter từ request vào where() / find() — client quyết định cả cột lẫn toán tử
Dữ liệu thù địch bị nối chuỗi: câu SQL / lệnh chứa cả cấu trúc lẫn dữ liệu, kể cả trong stored procedureStored procedure tự ghép chuỗi rồi EXECUTE — tham số hoá ở ngoài không cứu được bên trong

3. Các dạng injection phổ biến OWASP kể tên: SQL, NoSQL, OS command, ORM, LDAP, Expression Language (EL) hoặc OGNL — và nhấn mạnh "The concept is identical among all interpreters": khái niệm giống nhau ở mọi interpreter. Đây là câu nên mang vào phỏng vấn: nếu em hiểu một dạng ở mức cơ chế (dữ liệu và lệnh đi chung một kênh), em nhận ra được dạng thứ mười mà chưa ai dạy.

1.2 Thay đổi so với 2021

1. Thứ hạng: từ hạng 3 (A03:2021) xuống hạng 5. OWASP ghi rõ Injection "maintaining its position relative to A04:2025-Cryptographic Failures and A06:2025-Insecure Design" — tức là ba mục này cùng tụt vì hai mục mới/được nâng (Security Misconfiguration lên hạng 2, Software Supply Chain Failures vào hạng 3) vượt lên, chứ không phải vì injection bớt nguy hiểm.

2. Độ phủ test tăng, tỉ lệ xuất hiện giảm: 2021 có 94,04% ứng dụng được test một dạng injection, tỉ lệ xuất hiện cao nhất 19,09%, trung bình 3,37%; 2025 là 100% được test, cao nhất 13,77%, trung bình 3,08%. Số lần xuất hiện tăng từ 274.228 lên 1.404.249 — chủ yếu vì tập dữ liệu lớn hơn, không nên đọc thành "injection tăng năm lần".

3. Điểm impact giảm mạnh: trung bình impact có trọng số từ 7,15 (2021) xuống 4,32 (2025), trong khi điểm exploit gần như giữ nguyên (7,25 → 7,15). OWASP giải thích: XSS ("high frequency/low impact", hơn 30 nghìn CVE) áp đảo về số lượng so với SQL injection ("low frequency/high impact", hơn 14 nghìn CVE), nên kéo trung bình impact của cả nhóm xuống. Khi phỏng vấn, đừng nói "injection giờ ít nguy hiểm hơn"; nói đúng là trung bình bị XSS kéo xuống, còn một SQL injection đơn lẻ vẫn là sự cố nghiêm trọng nhất.

4. Danh sách CWE: từ 33 lên 37 CWE. So hai danh sách chính thức: 2025 thêm CWE-76, 86, 103, 104, 112, 114, 115, 129, 159, 493, 500 (phần lớn là biến thể "neutralization" và vài CWE đặc thù Java/Struts); bỏ CWE-73 (External Control of File Name or Path — 2021 còn nêu là CWE tiêu biểu), 75, 87, 100, 138, 184, 471, 652. CWE-73 chuyển sang mục nào trong 2025: chưa đối chiếu.

5. Prompt injection tách riêng: trang 2025 ghi rằng một lớp injection tương tự đã phổ biến ở LLM, nhưng được bàn riêng trong OWASP Top 10 for LLM (LLM01:2025 Prompt Injection). KB đã có bài riêng: Bảo mật agent — trong đó giải thích vì sao prompt injection không vá được như SQL injection (LLM không có kênh tham số tách khỏi kênh lệnh). Bài này chỉ dẫn link.

1.3 Bảng điểm chính thức

Chỉ sốA03:2021A05:2025
CWE được gom3337
Tỉ lệ xuất hiện cao nhất / trung bình19,09% / 3,37%13,77% / 3,08%
Độ phủ cao nhất / trung bình94,04% / 47,90%100,00% / 42,93%
Exploit / Impact trung bình có trọng số7,25 / 7,157,15 / 4,32
Tổng lần xuất hiện274.2281.404.249
Tổng CVE32.07862.445

Cách đọc bảng (chi tiết phương pháp ở Bài 0): incidence rate là tỉ lệ ứng dụng được test có ít nhất một CWE của nhóm; coverage là tỉ lệ ứng dụng được test cho nhóm này. Coverage 100% nghĩa là mọi công cụ / đội pentest trong tập dữ liệu đều kiểm injection — nó là thứ dễ tự động hoá nhất, nên cũng là thứ "có báo cáo" nhiều nhất.

1.4 Gom 37 CWE theo interpreter

37 CWE nhìn như danh sách học thuộc, nhưng gom theo interpreter nào đang bị lẫn lệnh với dữ liệu thì chỉ còn sáu nhóm:

InterpreterCWE tiêu biểuPhần trong bài
Database (SQL, ORM, HQL)CWE-89 SQL Injection, CWE-564 SQL Injection: Hibernate3.1, 3.2, 3.3, 4.1–4.4
Trình duyệt (HTML, JS, URL, CSS)CWE-79 XSS, 80, 83, 86, 116 (encode/escape output sai)3.5, 4.5
Shell / tiến trìnhCWE-77, CWE-78 OS Command Injection, CWE-88 Argument Injection, CWE-114 Process Control3.4, 4.6
Ngôn ngữ / template / expression của chính runtimeCWE-94 Code Injection, 95 Eval, 96, 97 SSI, 98 PHP file inclusion, 470 Unsafe Reflection, 917 Expression Language3.6, 4.7
Interpreter khác (LDAP, XPath, XML, HTTP header)CWE-90 LDAP, 91 XML, 643 XPath, 93/113 CRLF và HTTP Response Splitting, 6443.7
Nền chung: validate input, neutralize outputCWE-20 Improper Input Validation, CWE-74 (gốc của cả họ "Injection"), 76, 129, 1592, 5.3

1.5 Ranh giới với các mục khác

2. Bài toán gốc — dữ liệu và lệnh đi chung một kênh

1. Interpreter chỉ nhìn thấy một chuỗi. Khi code PHP ghép "SELECT ... WHERE name = '" . $name . "'", thứ đến tay MySQL không còn ranh giới nào giữa "phần dev viết" và "phần người dùng gõ": nó là một chuỗi ký tự duy nhất, và parser của MySQL phân tích cú pháp toàn bộ chuỗi đó. Nếu phần dữ liệu chứa ký tự có nghĩa cú pháp với parser (dấu nháy, dấu chấm phẩy, thẻ HTML, ký tự điều khiển shell), parser sẽ hiểu nó là cú pháp. Kỹ thuật viễn thông gọi hiện tượng này là in-band signaling (tín hiệu điều khiển đi chung kênh với dữ liệu) — và mọi lỗi injection đều là một biến thể của nó.

2. Vì sao dev vẫn ghép chuỗi. Vì ghép chuỗi là cách tự nhiên nhất để "tạo câu lệnh có giá trị thay đổi" trong mọi ngôn ngữ; nó chạy đúng với dữ liệu test (tên không dấu nháy, số nguyên); nó không có cảnh báo lúc compile. Lỗi chỉ hiện ra khi dữ liệu chứa đúng ký tự đặc biệt — tức là hiếm khi trong test, nhưng chắc chắn xảy ra khi có người chủ động thử.

3. Injection trước hết là bug đúng/sai, sau đó mới là bug bảo mật. Ví dụ minh hoạ: LMS có khoá học tên Children's Literature, hoặc giảng viên họ O'Neil. Một trang tìm kiếm ghép chuỗi sẽ sinh câu SQL có ba dấu nháy đơn — MySQL báo syntax error, người dùng thấy trang lỗi 500. Không ai tấn công cả, hệ thống vẫn hỏng. Đây là luận điểm thuyết phục nhất khi review code với dev junior: tham số hoá không phải "thêm bảo mật", nó là cách đúng duy nhất để truyền một giá trị; bản ghép chuỗi vừa sai với dữ liệu hợp lệ, vừa mở cửa cho dữ liệu cố ý.

4. Escape là cuộc đua theo từng interpreter, từng ngữ cảnh. Cách vá "thêm dấu gạch chéo trước ký tự đặc biệt" phụ thuộc vào: interpreter nào (MySQL, PostgreSQL, shell, HTML), chế độ nào (MySQL có chế độ NO_BACKSLASH_ESCAPES), bảng mã kết nối nào, và — với HTML — vị trí nào trong trang (giữa thẻ, trong attribute, trong <script>, trong URL). OWASP SQLi cheat sheet xếp "escape mọi input" vào mục "STRONGLY DISCOURAGED" và nói thẳng "we CANNOT guarantee that this option will prevent all SQL injections in all situations". Trang A05 cũng cảnh báo các kỹ thuật parse và escape chuỗi "error-prone and not robust in the face of minor changes to the underlying system".

5. Framework phủ đường chính, không phủ lối thoát hiểm. Query builder của Laravel, QueryBuilder của TypeORM, Blade {{ }}, JSX của React đều tách dữ liệu khỏi lệnh mặc định. Nhưng mỗi framework đều có escape hatch (lối thoát — API cố ý cho phép đưa chuỗi thô vào interpreter): DB::raw, whereRaw, orderByRaw, {!! !!}, dangerouslySetInnerHTML, v-html, $queryRawUnsafe, Process::run(string). OWASP gọi đúng tên hiện tượng này trong kịch bản số 2: "An application's blind trust in frameworks may result in queries that are still vulnerable".

6. Có những vị trí không thể tham số hoá. Tên bảng, tên cột, hướng sắp xếp, từ khoá SQL không thể truyền bằng tham số — PDO không bind được tên cột (docs Laravel: "PDO does not support binding column names"), TypeORM nói thẳng "Identifiers and SQL expressions are code, not data". OWASP A05 nhấn mạnh: tên cấu trúc do người dùng cung cấp là nguy hiểm và "This is a common issue in report-writing software". Đây là chỗ code "đã dùng ORM" vẫn dính injection nhiều nhất: màn hình danh sách có sort theo cột, report builder cho chọn trường.

flowchart TD
  S["Nguồn không tin cậy<br/>query string, body JSON, header, cookie,<br/>file upload, dữ liệu đã lưu từ trước"] --> C{"Code đưa vào interpreter<br/>bằng cách nào?"}
  C -- "Nối vào chuỗi lệnh" --> P1["Parser đọc cả chuỗi<br/>ký tự đặc biệt trong dữ liệu = cú pháp"]
  P1 --> X["Cấu trúc lệnh bị đổi<br/>(injection)"]
  C -- "Kênh tham số riêng / API an toàn" --> P2["Parser chỉ đọc khuôn lệnh cố định<br/>dữ liệu đi kênh khác"]
  P2 --> OK["Dữ liệu luôn chỉ là giá trị"]
  C -- "Vị trí không tham số hoá được<br/>(tên cột, hướng sort)" --> AL["Ánh xạ qua allowlist<br/>input chỉ là khoá tra bảng"]
  AL --> OK
Cách nói khi phỏng vấn: "Gốc của mọi injection là dữ liệu và lệnh đi chung một chuỗi, nên interpreter không phân biệt được đâu là phần dev viết, đâu là phần người dùng gõ. Cách phòng thủ chính vì vậy không phải là lọc ký tự, mà là tách kênh: khuôn lệnh cố định đi một đường, giá trị đi một đường khác. Chỗ nào không tách kênh được, như tên cột để sắp xếp, thì em không cho input đi vào lệnh nữa, mà dùng input làm khoá để tra trong một danh sách cho phép do code định nghĩa."

7. Dữ liệu "đã nằm trong hệ thống" không tự động đáng tin. Một tên khoá học được giảng viên nhập tuần trước, rồi hôm nay được một job báo cáo ghép vào SQL, hoặc được trang khác in ra không escape — đó là second-order injection (injection bậc hai) và stored XSS. Quy tắc dùng khi review: độ tin cậy của một giá trị không phụ thuộc vào việc nó lấy từ request hay từ database, mà phụ thuộc vào việc ai đã từng có quyền ghi nó. Vì thế phòng thủ phải đặt ở điểm dùng (sink — nơi giá trị đi vào interpreter), không chỉ ở điểm nhận (source).

3. Cơ chế — mỗi dạng sai ở giả định nào

Mỗi tiểu mục nêu: giả định sai trong code, hệ quả, ví dụ minh hoạ dựa trên kịch bản chính thức của OWASP (diễn giải, không chép chuỗi tấn công), và một sơ đồ cho luồng phòng thủ.

3.1 SQL injection do nối chuỗi — kịch bản số 1 của OWASP

1. Kịch bản (diễn giải từ OWASP): ứng dụng dựng câu SELECT * FROM accounts WHERE custID='...' bằng cách nối thẳng tham số id từ request vào giữa hai dấu nháy. Kẻ tấn công gửi một giá trị đóng dấu nháy sớm rồi thêm một điều kiện luôn đúng; câu lệnh đổi nghĩa thành "trả mọi dòng của bảng accounts". OWASP thêm: các biến thể nguy hiểm hơn có thể sửa, xoá dữ liệu, hoặc gọi stored procedure.

2. Giả định sai: dev nghĩ id "luôn là mã khách hàng" — một chuỗi chữ số. Nhưng HTTP không có kiểu dữ liệu; mọi tham số là chuỗi tuỳ ý cho tới khi code ép kiểu hay validate. Ranh giới "giá trị nằm trong dấu nháy" chỉ tồn tại trong đầu dev, còn SQL parser xác định ranh giới đó bằng chính ký tự nằm trong giá trị.

3. Ví dụ có số (minh hoạ): bảng accounts có 120.000 dòng, trang chỉ định trả 1 dòng. Khi điều kiện WHERE bị biến thành luôn đúng, câu lệnh trả 120.000 dòng — tức là vừa lộ dữ liệu của 119.999 khách hàng khác, vừa có thể làm PHP-FPM worker hết bộ nhớ khi hydrate 120.000 model. Một lỗi injection thường đi kèm một sự cố tài nguyên.

4. Hệ quả theo quyền của tài khoản DB: mức thiệt hại bị chặn trên bởi quyền của user DB mà app dùng. User chỉ có SELECT trên vài bảng → lộ dữ liệu các bảng đó. User là owner của schema → có thể sửa, xoá, đổi cấu trúc. User là superuser → trên một số hệ quản trị có thể đọc file, gọi lệnh hệ điều hành. Đây là lý do least privilege nằm trong "Additional Defenses" của OWASP (mục 5.4).

flowchart TD
  R["Request: tham số id"] --> Q{"Câu SQL được dựng thế nào?"}
  Q -- "Nối chuỗi vào giữa dấu nháy" --> W["Parser thấy dấu nháy trong dữ liệu<br/>điều kiện WHERE đổi nghĩa"]
  W --> L["Trả mọi dòng / sửa / xoá<br/>giới hạn bởi quyền user DB"]
  Q -- "Placeholder + bind" --> B["Parser chỉ thấy WHERE custID = $1<br/>giá trị gửi riêng"]
  B --> N["So sánh bằng với một chuỗi lạ<br/>trả 0 dòng, không lỗi"]

3.2 ORM và query builder: an toàn ở đường chính, hở ở escape hatch

1. Kịch bản số 2 của OWASP là cùng lỗi trên Hibernate Query Language: app nối tham số vào chuỗi HQL. OWASP nhận xét HQL có ít hàm nguy hiểm hơn SQL thô, nhưng nối chuỗi vẫn cho phép đổi điều kiện lọc và đọc dữ liệu không được phép. Bài học tổng quát: ORM không có phép màu — nó an toàn vì các API giá trị của nó tự bind tham số; ngay khi mình đưa một chuỗi do mình ghép vào chỗ ORM nhận "SQL fragment", ORM chuyển nguyên chuỗi đó xuống DB.

2. Laravel nói rõ ranh giới: docs Query Builder viết "The Laravel query builder uses PDO parameter binding to protect your application against SQL injection attacks", nhưng với raw expression thì "Raw statements will be injected into the query as strings" và "Laravel cannot guarantee that any query using raw expressions is protected against SQL injection". Các method selectRaw, whereRaw, havingRaw, orderByRaw, groupByRaw đều nhận tham số thứ hai là mảng binding — dùng raw không sai, nối giá trị vào chuỗi raw mới sai.

3. TypeORM nói còn rõ hơn trong SECURITY.md: mọi tham số được docs mô tả là nhận identifier hoặc SQL fragment — projection, chuỗi điều kiện, biểu thức sort/group, alias — được phát ra "as the developer wrote it" và API "does not promise they are injection-safe"; đưa input không tin cậy vào đó là lỗi của ứng dụng, "tương đương nối input vào raw query". Câu này đáng nhớ vì nó là hợp đồng trách nhiệm giữa thư viện và ứng dụng: thư viện chỉ bảo đảm vị trí parameter.

4. "ORM injection" kiểu thứ hai — client điều khiển cấu trúc filter. Dấu hiệu thứ ba trong định nghĩa OWASP là dữ liệu chưa làm sạch đi vào tham số tìm kiếm của ORM để lấy thêm bản ghi nhạy cảm. Dạng hay gặp: controller nhận một object filter từ JSON body và truyền thẳng vào where() / findBy() / query MongoDB. Không có ký tự đặc biệt nào, không có SQL nào bị ghép — nhưng client được quyền chọn cột nào để lọc và toán tử nào (với MongoDB: object toán tử thay cho giá trị vô hướng). Phòng thủ là DTO có danh sách trường cố định và kiểu cố định (mục 4.2, 3.7).

5. Một bẫy kiểu dữ liệu không phải injection nhưng cùng họ CWE-20: docs Laravel cảnh báo MySQL/MariaDB tự ép chuỗi sang số khi so sánh chuỗi với số, chuỗi không phải số thành 0 — ví dụ của docs: cột secret có giá trị aaa, chạy User::where('secret', 0) vẫn trả dòng đó. Giá trị đã được bind đúng cách, nhưng kiểu sai làm logic so khớp sai. Laravel khuyên ép kiểu mọi giá trị về đúng kiểu trước khi đưa vào query — đây là lý do validate kiểu (integer, string, uuid) vẫn có giá trị dù đã tham số hoá.

flowchart TD
  I["Input từ request"] --> K{"Đi vào vị trí nào của query builder?"}
  K -- "Vị trí giá trị: where(col, op, value),<br/>whereIn, :name trong TypeORM" --> V["Tự bind tham số<br/>an toàn"]
  K -- "Raw fragment có mảng binding" --> V
  K -- "Raw fragment tự nối chuỗi" --> X["Thư viện chuyển nguyên chuỗi xuống DB<br/>trách nhiệm của ứng dụng"]
  K -- "Vị trí identifier: tên cột, sort, alias" --> X
  K -- "Nguyên object filter từ client" --> F["Client chọn cột và toán tử<br/>lọc ra dữ liệu không được phép"]
  X --> D["Phải: allowlist / map cố định"]
  F --> D2["Phải: DTO trường cố định, kiểu cố định"]

3.3 Identifier injection: cột sắp xếp, bộ lọc động, report builder

1. Bối cảnh: trang danh sách bài nộp có nút sắp xếp theo "ngày nộp", "điểm", "họ tên"; frontend gửi ?sort=submitted_at&dir=desc. Cách nhanh nhất là orderBy($request->sort, $request->dir). Nhưng như mục 2 đoạn 6, tên cột không bind được, nên giá trị đó đi thẳng vào phần ORDER BY của câu SQL. Docs Laravel ghi cảnh báo này hai lần, đúng ở đầu trang và ở phần orderBy: "you should never allow user input to dictate the column names referenced by your queries, including 'order by' columns".

2. Giả định sai: "frontend chỉ gửi một trong ba giá trị". Frontend là code chạy trên máy người dùng; request có thể được gửi bằng bất kỳ công cụ nào. Điều kiện "chỉ một trong ba giá trị" phải được server thực thi.

3. Phòng thủ duy nhất đúng là allowlist dạng ánh xạ (OWASP SQLi cheat sheet, Defense Option 3 — "Allow-list Input Validation", với ví dụ validate tên bảng): input là khoá ("newest", "score"), server tra khoá trong một map do code định nghĩa để lấy tên cột thật. Input không bao giờ được chép vào SQL; nếu khoá không có trong map thì dùng mặc định hoặc trả 422. Cách "validate bằng regex chỉ chữ cái và gạch dưới" yếu hơn: nó chặn được ký tự cú pháp, nhưng vẫn cho phép chọn bất kỳ cột hợp lệ nào — kể cả password, remember_token — và sắp xếp theo cột bí mật là một kênh rò rỉ thông tin (thứ tự kết quả tiết lộ giá trị tương đối của cột đó).

4. Report builder là bản phóng to của bài toán này. Màn hình "tự tạo báo cáo" cho admin chọn trường, điều kiện, nhóm theo — tức là cho người dùng dựng cấu trúc query. OWASP nêu đích danh "report-writing software". Thiết kế an toàn: người dùng chọn từ một catalog trường có tên hiển thị → server map sang biểu thức SQL đã viết sẵn; toán tử chọn từ danh sách cố định; giá trị luôn bind; và câu lệnh chạy bằng user DB chỉ đọc, trên replica, có statement_timeout / max_execution_time. Moodle core cũng có "custom reports" theo đúng mô hình này (người dùng chọn từ các cột do code định nghĩa) — chi tiết ở mục 7.

flowchart TD
  U["?sort=score&dir=desc"] --> M{"Khoá có trong map do code định nghĩa?"}
  M -- "Có" --> C["Lấy tên cột thật từ map<br/>score → submissions.grade"]
  M -- "Không" --> E["Dùng mặc định hoặc 422<br/>(không bao giờ chép input vào SQL)"]
  C --> D{"dir thuộc {asc, desc}?"}
  D -- "Có" --> Q["orderBy(cột từ map, hướng từ map)"]
  D -- "Không" --> E

3.4 OS command injection và argument injection — kịch bản số 3

1. Kịch bản (diễn giải từ OWASP): ứng dụng dựng một chuỗi lệnh tra cứu DNS bằng cách nối tên miền từ request vào sau tên chương trình, rồi giao chuỗi đó cho hệ điều hành chạy. Kẻ tấn công gửi tên miền có kèm ký tự phân tách lệnh của shell và một lệnh thứ hai; shell chạy cả hai.

2. Giả định sai nằm ở chữ "shell". Khi gọi một chuỗi lệnh, runtime thường giao chuỗi cho /bin/sh -c; shell là một interpreter đầy đủ với dấu chấm phẩy, pipe, thay thế lệnh, biến môi trường, glob. Tức là "chạy chương trình X với tham số Y" bị biến thành "đưa cho shell một đoạn script". Mã nguồn Node.js cho thấy rõ: khi shell bật, các tham số được nối bằng dấu cách thành một chuỗi rồi chạy qua /bin/sh -c; khi shell tắt (mặc định của execFile/spawn), tên chương trình và mảng tham số được truyền nguyên vẹn xuống tầng spawn của hệ điều hành — không có shell nào để diễn giải ký tự đặc biệt.

3. Argument injection — lớp lỗi còn lại khi đã bỏ shell. OWASP OS Command cheat sheet: "Every OS Command Injection is also an Argument Injection". Dù đã escape ký tự shell hay đã dùng mảng tham số, nếu giá trị người dùng bắt đầu bằng dấu gạch ngang, chương trình được gọi có thể hiểu nó là một tuỳ chọn chứ không phải dữ liệu — và tuỳ theo chương trình, một tuỳ chọn có thể đổi file output, đổi cấu hình, thậm chí chạy lệnh khác. Cheat sheet nêu cách phòng: validate tham số bằng allowlist, và với lệnh hỗ trợ thì dùng -- để kết thúc phần tuỳ chọn; đồng thời nhắc rằng escapeshellarg() của PHP chống được chèn lệnh nhưng "an attacker can still pass a single argument to the command".

4. Phòng thủ số 1 của OWASP là đừng gọi lệnh hệ điều hành ("Avoid calling OS commands directly"): dùng hàm thư viện — mkdir() thay cho gọi lệnh mkdir, thư viện DNS của ngôn ngữ thay cho gọi nslookup, thư viện xử lý ảnh thay cho gọi CLI. Chỉ khi buộc phải gọi (ffmpeg, LibreOffice chuyển đổi tài liệu, pdftotext) mới dùng hai lớp: tham số hoá (API mảng tham số, không shell) và validate (lệnh nằm trong allowlist, tham số theo allowlist hoặc regex chặt + độ dài tối đa). Thêm lớp ngoài: chạy tiến trình bằng user ít quyền, trong container/sandbox riêng, có timeout.

flowchart TD
  N["Cần kết quả từ một chương trình ngoài"] --> L{"Có thư viện / hàm sẵn trong ngôn ngữ?"}
  L -- "Có" --> LIB["Dùng thư viện — không có interpreter nào để chèn"]
  L -- "Không" --> A["Gọi bằng API mảng tham số, shell tắt<br/>(Symfony/Laravel Process mảng, Node execFile/spawn)"]
  A --> V["Validate từng tham số: allowlist / regex chặt + độ dài<br/>giá trị không được bắt đầu bằng dấu gạch ngang"]
  V --> S["Dùng -- trước phần dữ liệu nếu chương trình hỗ trợ"]
  S --> R["Chạy bằng user ít quyền, sandbox, timeout"]

3.5 XSS — cùng một giá trị, năm ngữ cảnh, năm cách encode

1. Cơ chế: trình duyệt là interpreter của HTML, JavaScript, CSS và URL. Khi server (hoặc code JS phía client) chèn dữ liệu người dùng vào trang mà không encode đúng ngữ cảnh, trình duyệt có thể hiểu dữ liệu đó là thẻ, thuộc tính sự kiện, hay đoạn script, rồi chạy nó với quyền của chính trang web — đọc dữ liệu trang, gửi request kèm cookie phiên, thay đổi nội dung. Ba biến thể theo đường đi của dữ liệu: reflected (phản xạ ngay từ request), stored (lưu vào DB rồi hiện cho người khác — nguy hiểm nhất trong LMS vì giảng viên/admin xem nội dung do sinh viên nhập), DOM-based (toàn bộ xảy ra ở JS phía client, server không bao giờ thấy).

2. Giả định sai phổ biến nhất: "escape một lần là đủ". OWASP XSS cheat sheet có các mục riêng cho ngữ cảnh HTML, HTML attribute, JavaScript, CSS, URL, và nhấn mạnh "Using the wrong encoding method may introduce weaknesses or harm the functionality". Ví dụ: hàm e() của Laravel (đứng sau {{ }}) gọi htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') — đủ cho nội dung giữa thẻ và cho attribute có dấu nháy; nhưng không đủ khi giá trị là cả một URL đặt vào href (scheme javascript: không chứa ký tự nào cần encode HTML), hay khi đặt giá trị vào giữa một khối <script> (cần encode theo quy tắc JavaScript — Laravel có Js::from cho việc này).

3. Framework chống XSS mặc định, và mỗi framework có đúng một lối thoát cần soi: Blade có {!! !!} (docs: "Be very careful when echoing content that is supplied by users"); React có dangerouslySetInnerHTML (docs React: trừ khi markup đến từ nguồn hoàn toàn tin cậy, "it is trivial to introduce an XSS vulnerability this way"); Vue có v-html; DOM API có innerHTML, outerHTML, document.write, insertAdjacentHTML. Review XSS thực chất là review danh sách các lối thoát này, cộng các ngữ cảnh mà auto-escape không phủ (URL, script, style).

4. Khi người dùng thật sự cần soạn HTML (bài viết diễn đàn, mô tả khoá học bằng WYSIWYG editor), encode sẽ phá tính năng — chữ đậm hiện ra thành thẻ thô. OWASP chỉ định HTML Sanitization (lọc bỏ phần nguy hiểm, giữ lại tập thẻ/thuộc tính cho phép) và khuyên DOMPurify; kèm ba cảnh báo: sửa nội dung sau khi sanitize là mất tác dụng, đưa chuỗi đã sanitize cho thư viện khác có thể bị biến đổi lại, và phải cập nhật thư viện sanitize thường xuyên vì trình duyệt đổi hành vi.

5. Vì sao OWASP ghi XSS "impact thấp" mà vẫn phải làm nghiêm: "low impact" là trung bình trên hàng chục nghìn CVE, nhiều cái chỉ chạy được trên trang ít giá trị. Trong một LMS, một stored XSS ở trang chấm bài nghĩa là script chạy trong phiên của giảng viên — người có quyền sửa điểm. Mức nghiêm trọng thật phụ thuộc vào ai xem trang đó và trang đó làm được gì.

flowchart TD
  D["Giá trị do người dùng nhập"] --> W{"Đặt vào đâu trong trang?"}
  W -- "Giữa thẻ HTML" --> H["HTML entity encode<br/>Blade {{ }}, JSX {value}"]
  W -- "Attribute có dấu nháy" --> H
  W -- "href / src (URL)" --> U["Kiểm scheme http/https<br/>+ encode attribute"]
  W -- "Trong khối script" --> J["JSON encode an toàn cho HTML<br/>Js::from, data-* attribute"]
  W -- "Người dùng cần soạn HTML" --> S["Sanitize bằng thư viện (DOMPurify / HTML Purifier)<br/>rồi mới in thô"]
  W -- "Thuộc tính sự kiện, CSS, tên thẻ" --> NO["Không đặt dữ liệu người dùng vào đây"]

3.6 Code injection, eval, template injection, unsafe reflection

1. Interpreter ở đây là chính runtime của ứng dụng — PHP, JavaScript, template engine (Blade, Twig, Handlebars), expression language (OGNL của Struts, EL của Java, JNDI lookup của Log4j). Các CWE tương ứng: 94 (Code Injection), 95 (Eval Injection), 917 (Expression Language Injection), 470 (Unsafe Reflection — dùng input để chọn class hay method), 98 (PHP file inclusion — dùng input để chọn file include).

2. Giả định sai: "chuỗi này chỉ là cấu hình / template / tên class, không phải code". Ví dụ thường thấy trong code Laravel/Node: render một template Blade mà nội dung template lấy từ DB do người dùng soạn (email marketing tự soạn); new $className với $className từ request để chọn "loại export"; eval / new Function để tính công thức điểm do giảng viên nhập; dùng biến từ request làm đường dẫn include. Mỗi trường hợp đều trao cho người dùng một phần quyền viết code chạy trên server.

3. Phân biệt template dữ liệu và template code: cho người dùng soạn nội dung email với vài biến thay thế là nhu cầu thật. Cách an toàn là dùng một ngôn ngữ template không có khả năng tính toán (thay chuỗi đơn giản kiểu {{ten_sinh_vien}} bằng strtr với map cố định, hoặc engine "logic-less" chạy ở chế độ sandbox), không phải đưa nội dung đó cho Blade/Twig biên dịch thành PHP. Blade biên dịch template thành PHP thật — docs Blade ghi "you can put any PHP code you wish inside of a Blade echo statement".

4. Công thức tính điểm do giảng viên nhập (ví dụ minh hoạ, rất hay gặp trong LMS): thay vì eval, dùng một parser biểu thức có phạm vi giới hạn (chỉ số, phép tính, vài hàm đặt tên sẵn, biến là ID item điểm) — chính Moodle cũng tự viết bộ tính công thức điểm riêng thay vì đưa cho eval của PHP (chi tiết cài đặt: chưa verify).

flowchart TD
  T["Người dùng cung cấp: template / công thức / tên loại"] --> Q{"Có thật sự cần chạy logic tuỳ ý?"}
  Q -- "Không — chỉ thay biến" --> R["Thay chuỗi bằng map cố định<br/>(strtr / replace), không biên dịch"]
  Q -- "Cần tính toán giới hạn" --> P["Parser biểu thức riêng:<br/>chỉ số, phép tính, hàm đặt tên sẵn"]
  Q -- "Chọn class / handler" --> M["Map khoá → class do code định nghĩa<br/>(không new $input)"]
  Q -- "Cần plugin tuỳ ý" --> X["Đó là tính năng chạy code:<br/>chỉ cho admin tin cậy + sandbox + review"]

3.7 Các interpreter khác: NoSQL, LDAP, XPath, HTTP header, CSV

3.8 Hình động: cùng một input, hai cách gửi tới interpreter

Hình dưới gom cơ chế cốt lõi của cả bài. Hàng trên: khuôn lệnh và dữ liệu bị nối thành một chuỗi rồi mới tới parser — parser không còn cách nào biết ranh giới. Hàng dưới: khuôn lệnh tới parser trước và được phân tích thành plan cố định; dữ liệu tới sau, qua kênh riêng, và chỉ được dùng như giá trị. Với PostgreSQL đây đúng là luồng extended query protocol (Parse rồi Bind) — mục 5.2.

Hai cách gửi cùng một input tới interpreter: nối chuỗi và tách kênh Hàng trên là cách nối chuỗi: khuôn lệnh màu xanh và dữ liệu người dùng màu cam cùng đi vào hộp nối chuỗi, tạo thành một chuỗi duy nhất, chuỗi đó đi tới parser và parser đọc cả phần dữ liệu như cú pháp, kết quả màu đỏ là cấu trúc lệnh bị đổi. Hàng dưới là cách tách kênh: khuôn lệnh có chỗ trống đi tới parser trước, parser tạo ra một kế hoạch thực thi cố định; sau đó dữ liệu đi theo kênh riêng thẳng tới bước thực thi, chỉ được dùng làm giá trị, kết quả màu xanh lá là dữ liệu không thể đổi cấu trúc lệnh. A. Nối chuỗi (in-band) Code của dev Nối thành 1 chuỗi Parser của interpreter Kết quả khuôn lệnh input lệnh + input lẫn nhau Ký tự trong inputbị đọc thành cú phápcấu trúc lệnh đổi B. Tách kênh (parameterized / API an toàn) Code của dev Parse → plan cố định Kết quả kênh lệnh (Parse) kênh dữ liệu (Bind) WHERE id = $1 cấu trúc đã chốt input Input chỉ là giá trịcủa $1ký tự lạ = dữ liệu lạ,không đổi được lệnh Hàng B: lệnh tới parser trước, lúc đó dữ liệu chưa được gửi — không ký tự nào trong dữ liệu đổi được cấu trúc đã chốt. Áp dụng cho SQL (bind), shell (mảng tham số, không /bin/sh), HTML (textContent, JSX) — cùng một ý tưởng.
Hình 1 — Cơ chế cốt lõi của mọi phòng thủ injection. Hàng A (viền đỏ ở parser): khuôn lệnh (xanh dương) và input (cam) bị trộn thành một chuỗi (tím) trước khi tới parser, nên ký tự đặc biệt trong input trở thành cú pháp. Hàng B (viền xanh lá): khuôn lệnh có placeholder tới parser trước và được chốt thành plan; input đi kênh dữ liệu riêng, chỉ được thay vào vị trí $1. Tên placeholder $1 theo cú pháp PostgreSQL; MySQL dùng ?.
Cách nói khi phỏng vấn: "Em hình dung có hai đường. Đường thứ nhất là nối chuỗi: lệnh và dữ liệu bị trộn trước khi tới parser, nên parser không biết đâu là dữ liệu. Đường thứ hai là tách kênh: lệnh có chỗ trống tới parser trước và được chốt cấu trúc, dữ liệu tới sau qua kênh riêng và chỉ được điền vào chỗ trống. Với database đó là prepared statement, với shell là truyền mảng tham số không qua shell, với trình duyệt là gán vào text thay vì vào HTML. Một ý tưởng, ba interpreter."

4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)

Mỗi tiểu mục có: mẫu sai (ngắn, để nhận ra khi review), mẫu đúng (giải thích kỹ), một sơ đồ cho luồng phòng thủ. Hành vi Laravel đối chiếu với docs Laravel 13 (Query Builder, Blade, Processes) và mã nguồn laravel/framework nhánh 13.x (Database/Query/Builder.php, Database/Connectors/Connector.php, Process/PendingProcess.php, View/Compilers/Concerns/CompilesEchos.php, Support/helpers.php — đọc ngày 29/10/2026). NestJS dùng TypeORM / Prisma theo docs và SECURITY.md của TypeORM, docs Prisma, docs Node.js child_process, docs React (tra qua Context7). Tên bảng, biến, giá trị là minh hoạ, lấy bối cảnh một LMS.

4.1 Giá trị trong query: luôn bind, kể cả trong raw

// ===== SAI (Laravel) =====
$rows = DB::select("SELECT * FROM submissions WHERE student_code = '$code'");
$q->whereRaw("title LIKE '%{$kw}%'");                 // raw + nối chuỗi

// ===== SAI (NestJS) =====
await this.dataSource.query(`SELECT * FROM submissions WHERE student_code = '${code}'`);
qb.where(`s.title LIKE '%${kw}%'`);                    // template string = nối chuỗi
await prisma.$queryRawUnsafe(`SELECT * FROM "Submission" WHERE code = '${code}'`);

// ===== ĐÚNG (Laravel) =====
Submission::where('student_code', $code)->get();                       // query builder tự bind
DB::select('SELECT * FROM submissions WHERE student_code = ?', [$code]); // raw + binding vị trí
DB::select(
    'SELECT id, grade FROM submissions WHERE course_id = :course AND status = :status',
    ['course' => $courseId, 'status' => 'graded'],                   // raw + binding đặt tên
);
$q->whereRaw('LOWER(title) LIKE ?', ['%' . $kwEscaped . '%']);          // raw vẫn nhận mảng binding

// ===== ĐÚNG (NestJS + TypeORM) =====
await this.submissions.findBy({ studentCode: code });                    // repository API tự bind
qb.where('s.studentCode = :code', { code })
  .andWhere('s.courseId IN (:...courseIds)', { courseIds });             // :... mở rộng mảng thành danh sách tham số
await this.dataSource.query(
  'SELECT id, grade FROM submissions WHERE student_code = $1', [code],   // PostgreSQL: $1; MySQL: ?
);

// ===== ĐÚNG (NestJS + Prisma) =====
await prisma.$queryRaw`SELECT id FROM "Submission" WHERE code = ${code}`; // tagged template → tham số

Giải thích mẫu đúng, từng điểm:

  1. Đường chính của Laravel đã bind. where('student_code', $code) sinh SQL có placeholder và đưa $code vào mảng binding; docs: "There is no need to clean or sanitize strings passed to the query builder as query bindings". Vì thế việc tự addslashes hay tự escape trước khi đưa vào query builder là thừa và sai — giá trị lưu xuống DB sẽ chứa thêm dấu gạch chéo.
  2. Raw không đồng nghĩa với không an toàn. DB::select, whereRaw, selectRaw, orderByRaw, havingRaw đều có tham số binding. Quy tắc review: chuỗi SQL truyền cho các method raw phải là hằng số trong code (hoặc ghép từ các hằng số), không bao giờ chứa biến lấy từ input. Có thể ghi thành rule: "đối số đầu của *Raw không được là string interpolation".
  3. Binding đặt tên dễ đọc hơn khi query dài (:course, :status) — cùng cơ chế. Với TypeORM có một bẫy docs ghi rõ: "do not use the same parameter name for different values across the query builder. Values will be overridden" — dùng :courseId ở hai chỗ với hai giá trị khác nhau thì giá trị sau đè giá trị trước, query sai mà không báo lỗi.
  4. IN (:...ids) của TypeORM mở rộng mảng thành danh sách tham số; Laravel có whereIn làm việc tương tự. Ghép implode(',', $ids) vào chuỗi là sai dù $ids "chắc là số" — trừ khi mỗi phần tử đã được ép kiểu int (Laravel có whereIntegerInRaw làm đúng việc này: mã nguồn ép (int) từng giá trị trước khi đưa vào SQL, dùng khi danh sách rất dài để tránh giới hạn số tham số).
  5. Placeholder phụ thuộc driver: dataSource.query() của TypeORM chuyển nguyên câu xuống driver, nên PostgreSQL dùng $1, $2, MySQL dùng ?. Viết sai placeholder là lỗi cú pháp ngay khi chạy — lỗi to và sớm, tốt hơn lỗi im lặng.
  6. Prisma: docs Prisma ghi giá trị nội suy trong $queryRaw`...${x}` "are sent as query parameters" — tagged template không phải nối chuỗi, Prisma nhận riêng phần chữ và phần giá trị. $queryRawUnsafe nhận một chuỗi thường; docs đánh dấu "use with caution" và nó chỉ an toàn khi đi kèm tham số $1 riêng. Trong review, Unsafe trong tên hàm là tín hiệu phải đọc kỹ.
flowchart TD
  V["Giá trị từ input"] --> A{"API đang dùng"}
  A -- "where / findBy / whereIn" --> OK["Tự bind — xong"]
  A -- "*Raw / query() / queryRaw" --> B{"Chuỗi SQL có chứa biến không?"}
  B -- "Không: hằng số + placeholder" --> P["Truyền giá trị qua mảng binding<br/>hoặc tagged template"]
  B -- "Có: interpolation" --> X["Chặn ở review / rule SAST"]
  P --> OK
Cách nói khi phỏng vấn: "Em không cấm raw SQL, vì nhiều query báo cáo viết raw rõ ràng hơn query builder. Em cấm một thứ: chuỗi SQL được ghép từ biến. Chuỗi lệnh phải là hằng số trong code, mọi giá trị đi qua mảng binding hoặc tagged template. Và em nhắc team đừng tự escape trước khi đưa vào query builder, vì nó đã bind rồi, escape thêm chỉ làm hỏng dữ liệu."

4.2 Tên cột, hướng sắp xếp, bộ lọc động: allowlist dạng ánh xạ

// ===== SAI =====
$q->orderBy($request->input('sort'), $request->input('dir'));       // Laravel
$q->orderByRaw($request->input('sort'));
qb.orderBy(req.query.sort as string);                                   // NestJS
this.repo.find({ where: req.body.filter });                            // client chọn cả cột lẫn điều kiện

// ===== ĐÚNG (Laravel) =====
final class SubmissionIndexRequest extends FormRequest
{
    /** khoá công khai → cột thật; input chỉ là khoá */
    public const SORTS = [
        'newest' => 'submitted_at',
        'score'  => 'grade',
        'name'   => 'student_name',
    ];

    public function rules(): array
    {
        return [
            'sort'     => ['nullable', Rule::in(array_keys(self::SORTS))],
            'dir'      => ['nullable', Rule::in(['asc', 'desc'])],
            'status'   => ['nullable', Rule::in(['draft', 'submitted', 'graded'])],
            'per_page' => ['nullable', 'integer', 'between:1,100'],
        ];
    }
}

// Controller
$column = SubmissionIndexRequest::SORTS[$request->validated('sort') ?? 'newest'];
$query  = Submission::query()->where('course_id', $course->id);
if ($status = $request->validated('status')) {
    $query->where('status', $status);                // cột cố định trong code, chỉ giá trị đến từ input
}
$query->orderBy($column, $request->validated('dir') ?? 'desc')
      ->paginate($request->validated('per_page') ?? 20);

// ===== ĐÚNG (NestJS) =====
const SORTS = {
  newest: 'submission.submittedAt',
  score:  'submission.grade',
  name:   'student.fullName',
} as const;

export class ListSubmissionsQuery {
  @IsOptional() @IsIn(Object.keys(SORTS)) sort: keyof typeof SORTS = 'newest';
  @IsOptional() @IsIn(['ASC', 'DESC'])   dir: 'ASC' | 'DESC' = 'DESC';
  @IsOptional() @IsIn(['draft', 'submitted', 'graded']) status?: string;
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) perPage = 20;
}

// main.ts: app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
const qb = this.repo.createQueryBuilder('submission')
  .innerJoin('submission.student', 'student')
  .where('submission.courseId = :courseId', { courseId });
if (q.status) qb.andWhere('submission.status = :status', { status: q.status });
qb.orderBy(SORTS[q.sort], q.dir).take(q.perPage);

Giải thích mẫu đúng, từng điểm:

  1. Input là khoá, không phải tên cột. Client gửi score; server tra SORTS['score'] ra grade. Hai lợi ích: (a) không có đường nào để chuỗi của client chạm tới SQL; (b) API công khai không lộ tên cột thật — đổi schema không đổi hợp đồng API. Validation (Rule::in, @IsIn) chặn khoá lạ bằng 422 thay vì để lỗi nổ sâu bên trong.
  2. Hướng sắp xếp: mã nguồn Laravel 13 Builder::orderBy chỉ chấp nhận asc/desc (hoặc enum SortDirection), giá trị khác ném InvalidArgumentException. Đó là lưới an toàn cuối, không phải validation: để nó bắt thì người dùng nhận 500 và log đầy exception. Validate ở Form Request để trả 422 sạch.
  3. Cột được bọc quote khi Laravel sinh SQL (grammar "wrap" tên cột), nhưng docs vẫn cấm cho input quyết định tên cột — vì dù không đổi được cú pháp, input vẫn chọn được bất kỳ cột nào (mục 3.3 đoạn 3), và hành vi wrap với cú pháp đặc biệt (JSON path, alias) là chi tiết cài đặt không phải hợp đồng bảo mật. Đừng dựa vào nó.
  4. Bộ lọc động: mỗi filter là một nhánh if với cột cố định trong code; input chỉ cung cấp giá trị. Đây là lời giải cho dấu hiệu thứ ba của OWASP (ORM search parameters). whitelist: true của ValidationPipe loại bỏ thuộc tính không khai báo trong DTO, forbidNonWhitelisted trả lỗi khi có thuộc tính lạ — chi tiết và bẫy của hai cờ này ở Bài 1 (phần mass assignment).
  5. Giới hạn số lượng (per_page 1–100) không phải chống injection, nhưng là phần của "không để client quyết định cấu trúc query": không có giới hạn, một request có thể bắt DB trả 1 triệu dòng.
flowchart TD
  R["Query string: sort, dir, status, per_page"] --> V{"Form Request / DTO<br/>Rule::in, @IsIn, between"}
  V -- "Khoá lạ / kiểu sai" --> E["422 — dừng ở cửa"]
  V -- "Hợp lệ" --> M["Tra map: khoá → cột thật"]
  M --> F["Mỗi filter: cột cố định trong code<br/>giá trị bind"]
  F --> O["orderBy(cột từ map, asc|desc)<br/>+ giới hạn số dòng"]
Cách nói khi phỏng vấn: "Với những thứ không bind được như tên cột hay hướng sắp xếp, em không cố lọc chuỗi. Client chỉ gửi một khoá như 'score', server tra khoá đó trong một bảng ánh xạ do code định nghĩa để ra tên cột thật. Khoá lạ thì trả lỗi 422 ngay ở lớp validate. Nhờ vậy chuỗi của client không bao giờ chạm tới SQL, và API cũng không lộ tên cột trong database."

4.3 Tìm kiếm LIKE và danh sách IN — kể cả trong Moodle

// ===== SAI =====
$q->where('title', 'like', '%' . $kw . '%');            // không injection, nhưng % và _ của người dùng thành ký tự đại diện
$DB->get_records_sql("SELECT id FROM {course} WHERE fullname LIKE '%$search%'");   // Moodle: nối chuỗi
$DB->get_records_sql("SELECT id FROM {course} WHERE id IN (" . implode(',', $ids) . ")");

// ===== ĐÚNG (Laravel) =====
$kwEscaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $request->validated('q'));
$q->where('title', 'like', '%' . $kwEscaped . '%');     // vẫn là binding; chỉ trung hoà ký tự đại diện

// ===== ĐÚNG (NestJS) =====
const kwEscaped = q.keyword.replace(/[\\%_]/g, '\\$&');
qb.andWhere('course.title ILIKE :kw', { kw: `%${kwEscaped}%` });   // template string ở đây tạo GIÁ TRỊ, không tạo SQL

// ===== ĐÚNG (Moodle DML API) =====
$like   = $DB->sql_like('c.fullname', ':kw', false, false);          // không phân biệt hoa thường, dấu
$params = ['kw' => '%' . $DB->sql_like_escape($search) . '%'];
[$insql, $inparams] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'cid');
$rows = $DB->get_records_sql(
    "SELECT c.id, c.fullname FROM {course} c WHERE $like AND c.id $insql",
    $params + $inparams,
);

Giải thích mẫu đúng, từng điểm:

  1. Ký tự đại diện của LIKE không phải injection — cấu trúc câu lệnh không đổi, giá trị vẫn được bind. Nhưng người dùng gõ % hoặc _ sẽ được LIKE hiểu là "chuỗi bất kỳ" / "một ký tự bất kỳ": kết quả tìm kiếm sai nghĩa, và một mẫu toàn ký tự đại diện có thể bắt DB quét toàn bảng. Escape ba ký tự \, %, _ rồi mới bind; ký tự escape mặc định của LIKE trong cả MySQL và PostgreSQL là dấu gạch chéo ngược (theo docs hai hệ quản trị; chưa chạy lại ở bài này). Về hiệu năng của LIKE '%...%' (không dùng được B-tree) xem Scan node.
  2. Moodle DML API là phiên bản "framework" của các quy tắc trên. Mã nguồn moodle_database::sql_like() nhận tên cột và tên placeholder, trả về đoạn SQL có ESCAPE; nó còn tự kiểm: nếu tham số thứ hai chứa % (tức là dev đưa giá trị thay vì placeholder) thì phát debugging('Potential SQL injection detected, sql_like() expects bound parameters (? or :named)'). sql_like_escape() escape _ và %. get_in_or_equal() nhận mảng, trả về cặp (đoạn SQL IN (:cid1, :cid2...) hoặc = :cid1, mảng tham số).
  3. Vì sao đoạn "... WHERE $like AND c.id $insql" vẫn an toàn dù có nội suy biến: $like và $insql là SQL do API sinh ra từ tên cột hằng số và tên placeholder, không chứa giá trị người dùng. Đây là ngoại lệ hợp lệ duy nhất của quy tắc "chuỗi SQL không chứa biến" — biến chứa code do thư viện tin cậy sinh. Reviewer phải truy được nguồn gốc của mọi biến trong chuỗi SQL.
  4. {course} là cú pháp tên bảng của Moodle (thêm prefix cấu hình) — cũng là identifier do code cố định.
flowchart TD
  K["Từ khoá tìm kiếm"] --> E["Escape ký tự \, %, _<br/>(sql_like_escape / str_replace)"]
  E --> W["Thêm % hai đầu → thành GIÁ TRỊ"]
  W --> B["Bind vào placeholder :kw"]
  L["Danh sách id"] --> G["get_in_or_equal / whereIn / IN (:...ids)"]
  G --> B
  B --> S["SQL chỉ chứa: hằng số + đoạn do API sinh + placeholder"]
Cách nói khi phỏng vấn: "Ô tìm kiếm có hai lớp. Lớp một là bind giá trị như mọi chỗ khác. Lớp hai là trung hoà ký tự đại diện của LIKE, vì người dùng gõ phần trăm hay gạch dưới thì đó là chữ, không phải ký tự đại diện. Trong Moodle em dùng đúng API của nó: sql_like nhận tên placeholder chứ không nhận giá trị, sql_like_escape cho ký tự đại diện, get_in_or_equal cho danh sách. Nếu thấy chuỗi SQL có biến, em truy xem biến đó do API sinh hay do người dùng gửi."

4.4 Output ra trình duyệt: encode theo ngữ cảnh, sanitize khi cần HTML

{{-- ===== SAI (Blade) ===== --}}
<div class="desc">{!! $course->description !!}</div>           {{-- HTML người dùng soạn, in thô --}}
<a href="{{ $profile->website }}">Website</a>                    {{-- encode HTML không kiểm scheme của URL --}}
<script>var filters = {!! json_encode($filters) !!};</script>   {{-- JSON in thô trong khối script --}}

{{-- ===== ĐÚNG (Blade) ===== --}}
<h2>{{ $course->title }}</h2>                                  {{-- e() = htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE, UTF-8) --}}
<div class="desc">{!! $course->description_html !!}</div>       {{-- cột đã sanitize bằng allowlist thẻ khi lưu --}}
@if ($url = $profile->safeWebsiteUrl())                          {{-- trả null nếu scheme không phải http/https --}}
  <a href="{{ $url }}" rel="nofollow noopener">Website</a>
@endif
<script>var filters = {{ Js::from($filters) }};</script>        {{-- JSON đã escape an toàn cho HTML --}}

// ===== SAI (Next.js / React — frontend của API NestJS) =====
<div dangerouslySetInnerHTML={{ __html: course.description }} />
<a href={profile.website}>Website</a>

// ===== ĐÚNG (React) =====
<h2>{course.title}</h2>                                          // JSX tự escape giá trị text
const descriptionHtml = useMemo(() => ({ __html: DOMPurify.sanitize(course.description) }), [course.description]);
<div dangerouslySetInnerHTML={descriptionHtml} />               // object __html tạo sát chỗ sanitize
{isHttpUrl(profile.website) && <a href={profile.website} rel="nofollow noopener">Website</a>}

// ===== ĐÚNG (NestJS API) =====
@Get(':id') findOne(...) { return this.courses.findOne(id); }    // trả object → JSON, không trả chuỗi HTML ghép tay

Giải thích mẫu đúng, từng điểm:

  1. {{ }} làm gì, chính xác: mã nguồn CompilesEchos biên dịch {{ $x }} thành echo e($x), và helper e() gọi htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', $doubleEncode) — encode cả nháy đơn lẫn nháy kép (ENT_QUOTES), thay byte UTF-8 không hợp lệ bằng ký tự thay thế (ENT_SUBSTITUTE). Ngoại lệ: nếu giá trị là Htmlable (ví dụ HtmlString), e() trả nguyên toHtml() không encode — bọc dữ liệu người dùng vào new HtmlString(...) tương đương {!! !!}, là một lối thoát ẩn cần soi khi review.
  2. URL cần kiểm scheme, không chỉ encode. Encode HTML làm giá trị không thoát được khỏi attribute, nhưng một URL có scheme javascript: vẫn là attribute hợp lệ và chạy script khi bấm. safeWebsiteUrl() là một method nhỏ trên model: parse URL, chỉ trả về nếu scheme thuộc http/https. OWASP XSS cheat sheet có mục riêng cho URL context và mục "Common Mistake" cho đúng trường hợp này.
  3. Dữ liệu vào khối script dùng Js::from: docs Blade nói nó "will ensure that the resulting JSON has been properly escaped for inclusion within HTML quotes". json_encode thô thì không escape chuỗi đóng thẻ script, nên một giá trị có thể kết thúc khối script sớm. Cách còn sạch hơn: đặt dữ liệu vào data-* attribute bằng {{ }} và đọc bằng JS.
  4. Sanitize khi lưu hay khi hiển thị? Mẫu trên lưu hai cột: bản gốc (để sửa lại trong editor) và description_html đã sanitize (để hiển thị). Ưu: chi phí sanitize trả một lần; nhược: nâng cấp thư viện sanitize hoặc siết allowlist thì phải chạy lại cho dữ liệu cũ. Sanitize lúc hiển thị thì luôn theo quy tắc mới nhất nhưng tốn CPU mỗi request (có thể cache). OWASP nhắc: "If you sanitize content and then modify it afterwards, you can easily void your security efforts" — đừng chạy thêm biến đổi chuỗi (thay link, cắt ngắn bằng regex) sau bước sanitize. Thư viện phía PHP hay dùng là HTML Purifier (qua gói Laravel như mews/purifier) — chưa đối chiếu docs qua Context7.
  5. React: JSX coi giá trị trong {...} là text; dangerouslySetInnerHTML là lối thoát duy nhất ra HTML. Docs React khuyên tạo object {__html} "as close to where the HTML is generated as possible" và không tạo inline, để mọi HTML thô được đánh dấu rõ. Docs cũng nêu Trusted Types: nếu site bật Trusted Types, truyền TrustedHTML do policy tạo ra. Sanitize trong Next.js khi render phía server cần bản DOMPurify chạy được trên server (không có DOM) — chưa verify.
  6. API NestJS: trả object để Nest serialize JSON với Content-Type: application/json. Trả một chuỗi có chứa input người dùng từ controller dùng Express: Express res.send(string) mặc định đặt text/html (theo docs Express; chưa chạy) — một endpoint "chỉ trả text báo lỗi" có thể thành reflected XSS khi mở trực tiếp trên trình duyệt.
flowchart TD
  D["Dữ liệu người dùng cần hiển thị"] --> T{"Cần giữ định dạng HTML?"}
  T -- "Không" --> E["Encode theo ngữ cảnh<br/>Blade {{ }} / JSX {x} / textContent"]
  T -- "Có (editor)" --> S["Sanitize allowlist thẻ<br/>HTML Purifier / DOMPurify"]
  S --> N["Không biến đổi gì thêm sau sanitize"]
  N --> R["In thô: {!! !!} / dangerouslySetInnerHTML"]
  E --> U{"Là URL trong href/src?"}
  U -- "Có" --> SC["Kiểm scheme http/https"]
  R --> CSP["Lớp 2: CSP (mục 5.5)"]
  SC --> CSP
Cách nói khi phỏng vấn: "Mặc định em để framework encode: ngoặc nhọn đôi trong Blade, giá trị trong JSX. Review XSS với em là soi các lối thoát: in thô trong Blade, HtmlString, dangerouslySetInnerHTML, innerHTML. Mỗi lối thoát phải có lý do và dữ liệu đi qua đó phải được sanitize bằng thư viện có allowlist thẻ. Hai chỗ encode không đủ là URL, phải kiểm scheme, và dữ liệu đặt trong thẻ script, phải dùng Js::from hoặc data attribute."

4.5 Gọi tiến trình ngoài: mảng tham số, không shell, tên file do server sinh

// ===== SAI (Laravel) =====
Process::run("ffmpeg -i {$path} -ss 5 -frames:v 1 {$out}");        // chuỗi → chạy qua shell
shell_exec('pdftotext ' . $file->getClientOriginalName());          // tên file gốc của người dùng vào lệnh

// ===== SAI (NestJS / Node) =====
exec(`ffmpeg -i ${input} -frames:v 1 ${out}`);                       // exec luôn dùng shell
spawn('pdftotext', [file], { shell: true });                         // shell bật: tham số chỉ được nối bằng dấu cách

// ===== ĐÚNG (Laravel) =====
$in  = Storage::disk('local')->path($upload->storage_key);          // storage_key = UUID do server sinh
$out = storage_path("app/thumbs/{$upload->id}.jpg");                 // id là số nguyên của DB
$result = Process::timeout(60)->run([
    'ffmpeg', '-nostdin', '-i', $in, '-ss', '5', '-frames:v', '1', '-y', $out,
]);
$result->throw();                                                    // exit code ≠ 0 → exception

// ===== ĐÚNG (NestJS / Node) =====
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);

await run('ffmpeg',
  ['-nostdin', '-i', inputPath, '-ss', '5', '-frames:v', '1', '-y', outPath],
  { timeout: 60_000, maxBuffer: 1024 * 1024 },                       // không có shell: mặc định shell = false
);

Giải thích mẫu đúng, từng điểm:

  1. Laravel: mảng hay chuỗi quyết định có shell hay không. Mã nguồn PendingProcess::toSymfonyProcess(): nếu command là iterable → new Process($command) (Symfony Process chạy trực tiếp chương trình với mảng tham số); nếu là chuỗi → Process::fromShellCommandline($command) — chạy qua shell. Cùng một facade, khác hẳn mô hình bảo mật chỉ vì kiểu đối số. Docs Laravel xác nhận process có thể được gọi bằng mảng tham số (phần testing: "When the process was invoked with an array of arguments...").
  2. Node: execFile/spawn mặc định không shell; exec luôn chạy qua shell. Docs Node: "If the shell option is enabled, do not pass unsanitized user input to this function". Từ phiên bản có deprecation DEP0190 (kiểu Runtime), truyền mảng args cùng shell: true phát cảnh báo vì "the values are not escaped, only space-separated" — mã nguồn child_process.js nối tham số bằng dấu cách rồi đưa cho /bin/sh -c. Tức là "đã dùng mảng" không đủ; phải mảng và không shell.
  3. Chặn argument injection bằng thiết kế, không bằng lọc: đường dẫn input là đường dẫn tuyệt đối do server dựng từ UUID — nó bắt đầu bằng /, không bao giờ bắt đầu bằng dấu gạch ngang, và không chứa tên file gốc của người dùng. Tên gốc chỉ lưu trong DB để hiển thị (đi qua {{ }}). Nếu buộc phải đưa giá trị người dùng làm tham số, validate bằng allowlist/regex chặt và đặt sau -- nếu chương trình hỗ trợ (OWASP OS Command cheat sheet).
  4. Timeout và giới hạn output không chống injection nhưng chống file đầu vào bất thường làm treo worker: timeout(60) của Laravel, timeout và maxBuffer của Node (mặc định maxBuffer 1 MiB — vượt thì tiến trình con bị kết thúc).
  5. Lớp ngoài cùng: chạy loại job này trên queue riêng, container riêng có user không phải root, không mount secret của app — để nếu một chương trình như ffmpeg có lỗ hổng xử lý file, thiệt hại dừng trong container đó.
flowchart TD
  U["File upload"] --> K["Lưu bằng storage_key = UUID<br/>tên gốc chỉ vào DB"]
  K --> J["Job trên queue riêng"]
  J --> P{"Gọi chương trình ngoài"}
  P -- "Chuỗi lệnh / exec / shell: true" --> X["Shell diễn giải ký tự đặc biệt"]
  P -- "Mảng tham số, không shell" --> S["Process mảng / execFile"]
  S --> L["timeout + maxBuffer<br/>user không root, container riêng"]
Cách nói khi phỏng vấn: "Câu đầu tiên em hỏi là có cần gọi lệnh hệ điều hành không, hay có thư viện làm được. Nếu buộc phải gọi, như ffmpeg tạo thumbnail, em truyền mảng tham số và tắt shell: trong Laravel là truyền mảng cho Process, trong Node là execFile. Chú ý là truyền chuỗi cho Process của Laravel thì nó chạy qua shell. Tên file đưa vào lệnh là UUID do server sinh, không bao giờ là tên gốc người dùng upload, nên không có giá trị nào bắt đầu bằng dấu gạch ngang để bị hiểu thành tuỳ chọn."

4.6 Không đưa input vào eval, template engine hay reflection

// ===== SAI (Laravel) =====
$exporter = app('App\\Exports\\' . $request->type . 'Export');      // input chọn class
$html = Blade::render($template->body, ['student' => $s]);         // body do người dùng soạn → biên dịch thành PHP

// ===== SAI (NestJS) =====
const score = new Function('s', `return ${formula}`)(scores);         // công thức của giảng viên chạy như JS
const Handler = handlers[req.query.type as string];                   // object thường: khoá lạ vẫn có thể tra ra thuộc tính kế thừa

// ===== ĐÚNG (Laravel) =====
$exporter = match ($request->validated('type')) {                    // type đã Rule::in(['grades', 'attendance'])
    'grades'     => app(GradesExport::class),
    'attendance' => app(AttendanceExport::class),
};
$body = strtr($template->body, [                                      // chỉ thay biến, không biên dịch
    '{ten_sinh_vien}' => e($s->full_name),
    '{ten_khoa_hoc}'  => e($course->title),
]);

// ===== ĐÚNG (NestJS) =====
const EXPORTERS = new Map<string, Type<Exporter>>([
  ['grades', GradesExporter],
  ['attendance', AttendanceExporter],
]);
const cls = EXPORTERS.get(dto.type);                                  // dto.type đã @IsIn([...EXPORTERS.keys()])
if (!cls) throw new BadRequestException();
const exporter = this.moduleRef.get(cls, { strict: false });

Giải thích mẫu đúng, từng điểm:

  1. Chọn class bằng match / Map thay vì ghép tên class: tập class có thể được khởi tạo bằng đúng số nhánh viết trong code. Ghép tên class từ input là CWE-470 (Unsafe Reflection) — input có thể chọn một class khác cùng namespace hoặc khác namespace mà dev không nghĩ tới, và constructor/autoload của class đó chạy với quyền của app. Dùng Map trong JS thay vì object thường để khoá lạ không tra ra thuộc tính kế thừa từ prototype.
  2. Template người dùng soạn: thay biến, đừng biên dịch. Blade::render biên dịch chuỗi thành PHP và chạy — đưa nội dung do người dùng soạn vào đó là trao quyền viết PHP. strtr với map cố định chỉ thay chuỗi; giá trị thay vào vẫn đi qua e() vì kết quả sẽ thành HTML email. Nếu cần điều kiện/vòng lặp trong template của người dùng, chọn engine có chế độ sandbox và đọc kỹ trang bảo mật của engine đó — đây là quyết định kiến trúc, không phải chi tiết code.
  3. Công thức điểm: không dùng eval/new Function/vm của Node như một sandbox (docs Node ghi module vm không phải cơ chế bảo mật — theo hiểu biết về docs Node, chưa tra lại ở bài này). Dùng parser biểu thức giới hạn: chỉ số, toán tử, vài hàm đặt tên sẵn, biến là ID item điểm; parse thành cây, kiểm tra cây, rồi mới tính.
flowchart TD
  I["Input muốn chọn hành vi"] --> Q{"Loại hành vi"}
  Q -- "Chọn 1 trong N class/handler" --> M["match / Map cố định<br/>+ validate khoá"]
  Q -- "Nội dung có biến" --> S["strtr / replace với map biến<br/>giá trị vẫn encode"]
  Q -- "Công thức tính toán" --> P["Parser biểu thức giới hạn<br/>parse → kiểm cây → tính"]
  Q -- "Code tuỳ ý" --> A["Không phải input: là tính năng plugin<br/>chỉ admin tin cậy, có review"]
Cách nói khi phỏng vấn: "Nếu input có thể quyết định class nào được khởi tạo, file nào được include, hay đoạn chuỗi nào được biên dịch thành code, thì người dùng đang viết code trên server của mình. Em thay bằng bảng ánh xạ cố định cho việc chọn handler, thay chuỗi đơn giản cho template có biến, và parser biểu thức giới hạn cho công thức. Không có chỗ nào gọi eval hay render template từ nội dung người dùng soạn."

5. Phòng thủ nhiều lớp — mỗi lớp chặn gì, không chặn gì

5.1 Bảng tổng hợp

LớpChặn đượcKhông chặn đượcHợp nhất với bối cảnh
API an toàn: bind tham số, mảng tham số, JSX/Blade encodeToàn bộ injection ở vị trí giá trịVị trí identifier, escape hatch do dev tự mở, second-order khi chỗ dùng sau lại nối chuỗiMọi nơi — đây là lớp chính, OWASP gọi là "preferred option"
Allowlist dạng ánh xạIdentifier injection, chọn class/handler, chọn lệnhKhông áp dụng được cho giá trị tự do (tên, nội dung)Sort, filter, report builder, export type
Validate input (kiểu, độ dài, định dạng)Giảm bề mặt; chặn operator injection NoSQL (sai kiểu); lỗi sớm, 422 rõ ràngTrường cần ký tự đặc biệt (tên có dấu nháy, nội dung bài viết) — OWASP: "not a complete defense"Mọi endpoint, qua Form Request / DTO
Sanitize HTMLXSS khi người dùng được phép soạn HTMLNgữ cảnh ngoài HTML (URL, script); biến đổi sau sanitize; lỗi của chính thư viện (phải cập nhật)Editor nội dung khoá học, diễn đàn
Least privilege ở DB / OSGiới hạn thiệt hại khi các lớp trên thủngKhông ngăn được việc đọc dữ liệu mà user DB vốn được đọcLuôn có; tách user cho app, migration, báo cáo
CSPGiảm khả năng script chèn vào chạy được (với CSP chặt, không unsafe-inline)Không sửa lỗi gốc; CSP lỏng gần như vô dụng; không áp dụng cho API JSONTrang render HTML, đặc biệt trang admin / chấm bài
WAFChặn mẫu phổ biến, "virtual patch" tạm cho lỗ hổng đã biết trong lúc chờ váBiến thể mới, logic riêng của app; dương tính giả chặn người dùng thậtHệ thống không sửa code nhanh được (Moodle core, plugin bên thứ ba)
SAST / review / testPhát hiện sớm sink nguy hiểm trước khi mergeKhông chứng minh được "không còn lỗi"; dương tính giả cao nếu không tinh chỉnhCI của mọi repo (mục 6)

5.2 Vì sao bind tham số chặn được injection — ở mức giao thức

1. PostgreSQL — extended query protocol (theo docs PostgreSQL, chương Frontend/Backend Protocol): client gửi message Parse chứa câu lệnh với placeholder $1, $2; server phân tích cú pháp và tạo prepared statement. Sau đó client gửi Bind chứa giá trị các tham số (dạng text hoặc binary, kèm độ dài) để tạo portal, rồi Execute. Giá trị trong Bind không bao giờ đi qua SQL parser — nó được đưa thẳng vào vị trí $1 của cây lệnh đã phân tích. Đây là phiên bản giao thức của Hình 1 hàng B.

2. Laravel dùng prepare thật, không giả lập: mã nguồn Connector.php đặt PDO::ATTR_EMULATE_PREPARES => false trong option mặc định. KB đã có lab thật với pdo_pgsql trong bài Khi plan sai (mục 3.5): mỗi câu là một prepared statement riêng (pdo_stmt_...), chạy một lần, nên luôn được custom plan. Hệ quả hiệu năng (không tái dùng plan, thêm một round-trip Parse) là cái giá nhỏ cho ranh giới lệnh/dữ liệu rõ ràng.

3. "Emulated prepare" là gì và vì sao vẫn thường an toàn: khi giả lập, driver tự thay placeholder bằng giá trị đã được escape ở phía client rồi gửi một chuỗi SQL hoàn chỉnh. An toàn phụ thuộc vào việc hàm escape biết đúng bảng mã của kết nối. Moodle là ví dụ thật: driver mysqli_native_moodle_database có hàm emulate_bound_params() (mã nguồn: tách câu theo dấu ?, giá trị chuỗi đi qua mysqli::real_escape_string rồi bọc trong nháy đơn; số cũng được bọc nháy vì "mysql is using weird automatic int casting"). Cách này an toàn khi bảng mã kết nối được đặt bằng API của driver (để real_escape_string biết bảng mã), không bằng một câu SET NAMES riêng — đây là khuyến nghị chung của docs PHP mysqli về charset; chưa verify trên Moodle cụ thể. Với dev viết plugin, điều quan trọng là: dù driver bên dưới tham số hoá thật hay giả lập, mình vẫn dùng placeholder — quyết định giao thức là việc của driver.

4. Stored procedure không tự động an toàn: OWASP A05 ghi chú "Even when parameterized, stored procedures can still introduce SQL injection if PL/SQL or T-SQL concatenates queries and data or executes hostile data with EXECUTE IMMEDIATE or exec()". Với PostgreSQL, câu tương đương là PL/pgSQL EXECUTE với chuỗi ghép — phải dùng EXECUTE ... USING cho giá trị và format('%I', ...) / quote_ident cho identifier (theo docs PL/pgSQL; chưa chạy lại ở bài này).

sequenceDiagram
  participant App as Laravel (pdo_pgsql)
  participant PG as PostgreSQL
  App->>PG: Parse — SELECT ... WHERE student_code = $1
  PG-->>App: ParseComplete (cấu trúc lệnh đã chốt)
  App->>PG: Bind — $1 = giá trị người dùng (độ dài + byte)
  App->>PG: Execute
  PG-->>App: DataRow ... CommandComplete
Cách nói khi phỏng vấn: "Với PostgreSQL, prepared statement đi hai bước trên giao thức: bước một gửi câu lệnh có chỗ trống để server phân tích và chốt cấu trúc, bước hai mới gửi giá trị kèm độ dài. Giá trị không bao giờ đi qua bộ phân tích cú pháp, nên dù chứa ký tự gì cũng không đổi được câu lệnh. Laravel tắt chế độ giả lập nên dùng đúng cơ chế này. Có driver giả lập bằng cách escape phía client, như driver MySQL của Moodle, vẫn ổn nếu charset kết nối đặt đúng, nhưng với dev thì quy tắc không đổi: luôn dùng placeholder."

5.3 Validate input: cần nhưng không đủ

1. OWASP đặt "positive server-side input validation" (validate dương — mô tả cái được phép, không liệt kê cái bị cấm) ở vị trí bổ sung và nói rõ nó không phải phòng thủ đầy đủ vì nhiều trường cần ký tự đặc biệt. Ví dụ trong LMS: họ tên có dấu nháy (O'Neil), tên khoá học có dấu và ký hiệu, nội dung bài tự luận chứa bất kỳ ký tự gì. Không có regex nào vừa chặn được mọi thứ nguy hiểm cho mọi interpreter phía sau vừa cho qua mọi giá trị hợp lệ — vì "nguy hiểm" phụ thuộc interpreter, mà lúc validate mình chưa biết giá trị sẽ đi đâu.

2. Validate mạnh nhất ở chỗ miền giá trị nhỏ và biết trước: kiểu số nguyên cho ID, UUID, enum trạng thái, khoá sắp xếp, mã ngôn ngữ. Ở đó validate gần như là allowlist và đóng vai trò phòng thủ chính. Ở trường văn bản tự do, validate chỉ còn độ dài và bảng mã hợp lệ (UTF-8) — phòng thủ phải nằm ở sink.

3. Một lợi ích hay bị bỏ qua: validate kiểu chặn operator injection của NoSQL và bẫy ép kiểu của MySQL (mục 3.2 đoạn 5) — hai lỗi mà bind tham số không chặn được vì giá trị đã được bind đúng, chỉ có kiểu sai.

5.4 Least privilege ở database — giới hạn thiệt hại

OWASP SQLi cheat sheet: "DO NOT ASSIGN DBA OR ADMIN TYPE ACCESS TO YOUR APPLICATION ACCOUNTS", mỗi ứng dụng một user DB riêng, chỉ cấp quyền đọc khi chỉ cần đọc, dùng view để giới hạn cột. Áp vào stack thực tế (minh hoạ):

User DBDùng bởiQuyềnLý do
app_rwWeb + workerSELECT/INSERT/UPDATE/DELETE trên bảng nghiệp vụ; không DDL, không superuserInjection còn sót không đổi được schema, không đọc file hệ thống
app_migrateBước migration trong CI/CDOwner schema, DDLChỉ tồn tại trong job deploy, không nằm trong env của app đang chạy
report_roBáo cáo, report builder, chạy trên replicaSELECT trên view báo cáo; statement_timeout / giới hạn thời gianReport builder là chỗ rủi ro identifier injection cao nhất (mục 3.3) — nếu thủng, chỉ đọc được view
portal_ro*Dịch vụ khác dùng chung DB (ví dụ portal NestJS)Chỉ bảng/view nó cầnMột lỗ hổng ở dịch vụ phụ không thành lỗ hổng của toàn bộ DB

Với PostgreSQL có thể đẩy xa hơn bằng Row-Level Security để giới hạn theo tenant ngay trong DB — KB đã có lab ở ABAC và RBAC. Lưu ý trung thực: least privilege không ngăn được injection đọc dữ liệu mà user DB vốn được phép đọc — với app_rw, đó thường là gần như toàn bộ dữ liệu nghiệp vụ.

5.5 CSP: lớp thứ hai cho XSS, không phải lớp thứ nhất

1. Content Security Policy cho trình duyệt biết script được phép tải từ đâu và có được chạy script inline không. Với CSP chặt (nonce hoặc hash cho script, không unsafe-inline), một đoạn script bị chèn vào HTML sẽ không chạy dù encode bị sót.

2. OWASP XSS cheat sheet có hẳn mục "Sole Reliance on Content-Security-Policy (CSP) Headers" trong phần anti-pattern: họ ủng hộ CSP "when it is used properly", như "defense-in-depth" và "Customized for each individual application", và phản đối một chính sách chung cho cả doanh nghiệp. Trade-off thực tế: app cũ nhiều inline script/handler (Moodle, nhiều theme Blade cũ) phải refactor nhiều mới bật được CSP chặt; bật CSP lỏng cho có thì gần như không chặn gì. Cách triển khai thực tế là bật Content-Security-Policy-Report-Only trước, thu report vài tuần, sửa dần, rồi mới enforce. Cấu hình header và chỗ đặt (Nginx hay middleware) ở Bài 2.

5.6 WAF: vá tạm, không phải sửa

WAF (Web Application Firewall) so request với tập luật (ví dụ OWASP Core Rule Set). Giá trị thật: chặn quét tự động hàng loạt và làm "virtual patch" khi một lỗ hổng đã công bố trong phần mềm mình không sửa code nhanh được (Moodle core, plugin bên thứ ba) — trong lúc chờ nâng cấp. Giới hạn: WAF không biết ngữ nghĩa của app nên vừa có thể bỏ lọt biến thể, vừa chặn nhầm nội dung hợp lệ (bài tự luận về lập trình có chứa từ khoá SQL là ví dụ kinh điển trong LMS — dương tính giả). Nếu dùng, phải có quy trình xử lý dương tính giả và không được coi WAF là lý do hoãn sửa code.

5.7 Nếu chỉ có một sprint: thứ tự ưu tiên

  1. Kiểm kê sink bằng grep/SAST (danh sách ở 6.1): mọi *Raw, DB::select/statement có biến, {!! !!}, dangerouslySetInnerHTML, exec/shell_exec/Process::run(string), eval/new Function. Thường chỉ vài chục chỗ.
  2. Sửa những sink nhận input trực tiếp: bind giá trị, allowlist cho sort/filter.
  3. Tách user DB: app không chạy bằng owner/superuser; báo cáo chạy bằng user chỉ đọc.
  4. Thêm rule SAST vào CI để sink mới phải qua review có chủ đích.
  5. CSP Report-Only cho trang quan trọng nhất (chấm bài, admin), lên kế hoạch enforce.

6. Phát hiện & vận hành

6.1 Code review: mẫu cần soi

StackSink cần soiCâu hỏi khi review
Laravel / PHPDB::raw, selectRaw, whereRaw, orderByRaw, havingRaw, DB::select/statement/unprepared, orderBy($input)Chuỗi SQL có biến không? Biến đó đến từ đâu? Identifier có qua map?
Blade{!! !!}, new HtmlString, href="{{ $url }}", dữ liệu trong <script>Có sanitize không? URL có kiểm scheme không? Script dùng Js::from?
PHP runtimeexec, shell_exec, system, passthru, proc_open, Process::run("..."), eval, include $var, new $var, Blade::render($userText)Có tránh được không? Mảng tham số? Khoá có qua allowlist?
TypeORM / Prismaquery(`...${}`), where(`...${}`), orderBy(input), $queryRawUnsafe, Prisma.raw, find({ where: req.body })Template string tạo SQL hay tạo giá trị?
Nodeexec, execSync, shell: true, eval, new Function, vm.run*Có shell không? Input có thành code không?
React / DOMdangerouslySetInnerHTML, innerHTML, insertAdjacentHTML, href={input}Đã sanitize sát chỗ dùng chưa?
Moodle pluginget_records_sql / execute với chuỗi có biến, sql_like nhận giá trị thay vì placeholder, output không qua hàm format/escape của MoodleDùng placeholder, get_in_or_equal, sql_like_escape?
Cách nói khi phỏng vấn: "Em không review injection bằng cách đọc từng dòng tìm lỗi, mà bằng danh sách sink: những API cố ý cho chuỗi thô đi vào interpreter. Mỗi stack chỉ có khoảng mười cái tên như vậy, nên grep hoặc rule SAST tìm ra hết. Với mỗi chỗ, em hỏi đúng hai câu: chuỗi đi vào đó có biến không, và biến đó do code sinh hay do người dùng gửi."

6.2 Kiểm tra tự động

6.3 Tín hiệu trong production

7. Áp dụng vào hệ thống trong CV

Nguồn: cv-v3.md. Dấu * = giả định, CV không nêu. Không bịa thông tin về hệ thống — chỗ nào chưa biết thì nêu thành câu hỏi để tự kiểm lại trước buổi phỏng vấn.

7.1 LMS Uni — Moodle, student portal NestJS + Next.js dùng chung MySQL, Redis, Nginx LB, 5 app node

  1. Plugin và báo cáo tự viết trên Moodle. CV có "chuyển báo cáo thống kê nặng sang queue job và bảng tổng hợp sẵn". Câu interviewer có thể hỏi: "báo cáo đó lọc theo khoá học, theo khoảng thời gian, sắp xếp theo cột — anh dựng query thế nào?" Câu trả lời đúng: dùng Moodle DML API với placeholder (get_records_sql + mảng tham số, get_in_or_equal cho danh sách khoá học, sql_like + sql_like_escape cho tìm kiếm), cột sắp xếp qua map cố định (mục 4.2, 4.3). Nếu báo cáo cho admin chọn cột: catalog cột định nghĩa sẵn, giống mô hình custom reports của Moodle core.
  2. Báo cáo chạy trên replica bằng user chỉ đọc*. CV có "đẩy truy vấn đọc (báo cáo, danh sách khoá học) sang replica". Nếu kết nối replica dùng một user MySQL chỉ có SELECT, đó là least privilege tự nhiên cho đường báo cáo — một injection sót lại ở báo cáo không ghi được gì. Cần tự kiểm: replica dùng chung user với primary hay user riêng? (CV không nêu.) Lưu ý thêm: replica read_only không chặn user có quyền SUPER/CONNECTION_ADMIN — user riêng có quyền tối thiểu vẫn là cách đúng (theo docs MySQL về read_only; chưa verify ở bài này).
  3. Student portal NestJS dùng chung database với Moodle. Đây là điểm nhạy cảm nhất: portal đọc/ghi trực tiếp bảng Moodle (mdl_*)*, nên một injection ở portal là injection vào dữ liệu Moodle. Câu trả lời nên có: portal dùng query builder có bind (TypeORM/Knex/Prisma*), sort/filter qua DTO @IsIn, và — lý tưởng — một user MySQL riêng cho portal chỉ có quyền trên các bảng nó cần. Nếu thực tế portal dùng chung user với Moodle, đó là một điểm cải thiện nên tự nêu ra trước khi bị hỏi.
  4. Driver MySQL của Moodle giả lập bind (mục 5.2 đoạn 3): khi CV nói "chuyển MySQL sang utf8mb4" (dự án Topica), có thể nối sang ý: charset kết nối phải được cấu hình qua driver (Moodle đọc dboptions trong config.php*), vì escape phía client phụ thuộc charset — một câu cho thấy hiểu cơ chế, không chỉ dùng API.
  5. Output trong Moodle. Plugin hiển thị dữ liệu người dùng phải qua hàm output của Moodle (s(), format_string(), format_text() với bộ lọc HTML) và template Mustache (mặc định escape với {{ }}, thô với {{{ }}}) — ngữ nghĩa cụ thể từng hàm: chưa verify ở bài này, cần đọc Moodle dev docs "Output API" trước khi trích dẫn. Rủi ro thực tế trong LMS: stored XSS trong bài nộp/diễn đàn hiển thị cho giảng viên — người có quyền sửa điểm.
  6. Next.js portal: nội dung khoá học soạn bằng editor của Moodle (HTML) được portal hiển thị lại* — nếu dùng dangerouslySetInnerHTML thì phải sanitize ở portal, không được giả định "Moodle đã lọc rồi" (Moodle lọc khi render bằng format_text, dữ liệu trong DB có thể là HTML gốc — chưa verify).
  7. Worker "chấm điểm"*: nếu chấm tự động bài lập trình (chạy code sinh viên), đó không còn là injection mà là chạy code không tin cậy theo thiết kế — cần sandbox thật (container cô lập, không mạng, giới hạn tài nguyên). CV không nói loại chấm điểm; đừng tự nhận nếu không làm.
  8. Link tải báo cáo xác thực bằng JWT: không phải injection — xem Bài 4 (ghim thuật toán) và Bài 1 (ai được tải file nào).

7.2 LMS Micro-learning — Laravel + Livewire, NestJS, Next.js, PostgreSQL, Redis, MongoDB, MinIO

  1. Tối ưu query bằng EXPLAIN ANALYZE và pg_stat_statements (CV): khi viết lại query chậm, dev hay chuyển sang raw SQL — đúng lúc dễ lọt nối chuỗi. Câu nên nói: "query viết lại bằng raw vẫn dùng binding và chuỗi SQL là hằng số; sau mỗi release em so danh sách dạng câu trong pg_stat_statements với baseline để thấy câu có cấu trúc lạ" (mục 6.3).
  2. Livewire: public property của component có thể bị client sửa giá trị trước khi gửi lại server — KB có bài riêng: Livewire public property. Nếu một property như $sortField đi thẳng vào orderBy, đó chính là identifier injection của mục 3.3; validate bằng Rule::in hoặc map trong method updated* / trước khi query.
  3. MongoDB* (CV liệt kê, không nói dùng cho việc gì): nếu có endpoint dựng filter Mongo từ body JSON, kiểm validate kiểu (mục 3.7).
  4. MinIO + xử lý file*: nếu có job tạo thumbnail/convert tài liệu bằng chương trình ngoài, áp mục 4.5: tên object là UUID, mảng tham số, container riêng.
  5. Codebase dùng chung cho 4 trường (CV): một lỗi injection trong module dùng chung là lỗi ở cả 4 trường cùng lúc — lý do đặt rule SAST ở CI của repo dùng chung, và lý do tách tenant ở tầng DB (RLS) đáng cân nhắc. Xem Codebase dùng chung.

7.3 Dự án cũ — nâng cấp Moodle 2.7 → 3.5, PHP 5.6 → 7.1 (Topica); e-commerce Express (TopRate)

7.4 Trả lời mẫu

Cách nói khi phỏng vấn: "Ở hệ thống LMS, chỗ em lo nhất về injection không phải form đăng nhập mà là báo cáo và danh sách có sort, filter, vì đó là chỗ dev hay viết raw SQL và chỗ tên cột không bind được. Quy tắc của team là chuỗi SQL phải là hằng số, giá trị đi qua binding — trong Moodle là placeholder của DML API, trong Laravel và NestJS là binding của query builder — còn tên cột thì đi qua bảng ánh xạ. Báo cáo chạy trên replica, nên thêm một lớp là user database chỉ đọc. Còn XSS thì chỗ rủi ro là nội dung soạn bằng editor hiển thị cho giảng viên, nên phải sanitize và không in thô ở bất kỳ đâu khác."

8. Anti-patterns & bài học thực tế

8.1 Sự cố công khai: điều gì sai và kiểm soát nào giúp

Sự cốĐiều gì sai (theo nguồn công khai)Kiểm soát nào giúp
MOVEit Transfer (2023), CVE-2023-34362Lỗ hổng SQL injection trong ứng dụng truyền file, bị nhóm Cl0p khai thác hàng loạt để lấy dữ liệu của nhiều tổ chức (CISA advisory AA23-158A)Tham số hoá; least privilege của tài khoản DB; với phần mềm mua về: theo dõi advisory, vá nhanh, WAF làm virtual patch tạm
Equifax (2017), CVE-2017-5638Apache Struts đánh giá biểu thức OGNL từ header Content-Type trong lỗi xử lý upload — một dạng expression language injection (CWE-917 nằm trong A05); bản vá có sẵn nhưng chưa được ápKhông đánh giá biểu thức trên dữ liệu request; quản lý bản vá phụ thuộc (A03 — Bài 3)
Log4Shell (2021), CVE-2021-44228Log4j diễn giải cú pháp lookup trong chuỗi được log, cho phép tải và chạy code từ xa; NVD gắn cả CWE-917Dữ liệu đi vào thư viện log phải là dữ liệu — tính năng diễn giải trên dữ liệu không tin cậy phải tắt mặc định; kiểm kê phụ thuộc (SBOM)
TalkTalk (2015, Anh)SQL injection vào trang web cũ kế thừa từ thương vụ mua lại; ICO phạt 400.000 bảng, nêu rằng công ty không vá một lỗ hổng đã biết từ nhiều nămKiểm kê và dọn hệ thống legacy; quét định kỳ; trách nhiệm sở hữu rõ ràng cho từng hệ thống
Samy (MySpace, 2005)Stored XSS tự lan qua hồ sơ người dùng, hơn một triệu hồ sơ trong khoảng một ngày (theo mô tả công khai của tác giả)Sanitize bằng allowlist thay vì blocklist; CSP; encode theo ngữ cảnh

Điểm chung đáng nói khi phỏng vấn: ba trong năm sự cố nằm ở phần mềm/thư viện bên thứ ba hoặc hệ thống legacy, không phải code mới của team. Phòng thủ injection ở mức tổ chức vì thế gồm cả kiểm kê phụ thuộc và vá nhanh (A03), không chỉ "dev viết query đúng".

8.2 Anti-pattern thường gặp khi review

  1. "Dùng ORM rồi nên an toàn" — trong khi sort, filter, report vẫn nối chuỗi vào raw/identifier (OWASP kịch bản 2).
  2. Tự escape trước khi đưa vào query builder — thừa, làm hỏng dữ liệu, và tạo ảo giác rằng escape là cơ chế bảo vệ.
  3. Blocklist từ khoá (lọc SELECT, <script>) — vừa chặn nhầm nội dung hợp lệ (bài tập SQL của sinh viên), vừa không đủ vì interpreter có nhiều cách viết tương đương.
  4. Validate ở frontend, tin ở backend — request có thể không đi qua frontend.
  5. Tin dữ liệu "từ database của mình" — second-order injection và stored XSS (mục 2 đoạn 7).
  6. Sanitize xong rồi xử lý tiếp chuỗi (thay link, cắt ngắn bằng regex, ghép thêm HTML) — mất tác dụng sanitize.
  7. App kết nối DB bằng user owner/superuser vì "migration cần" — tách user migration ra khỏi runtime.
  8. Gọi lệnh qua shell "vì tiện pipe" — dùng API pipeline của thư viện process hoặc xử lý stream trong code.
  9. Hiện lỗi SQL chi tiết ra người dùng (APP_DEBUG bật ở production) — không tạo injection nhưng làm việc dò tìm dễ hơn rất nhiều (Bài 2).

9. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
Prepared statement chống SQL injection bằng cách nào?Câu lệnh có placeholder được gửi và phân tích trước; giá trị gửi sau qua kênh riêng, không đi qua parser — không thể đổi cấu trúc đã chốt.
Có cái gì không tham số hoá được?Tên bảng, tên cột, hướng sort, từ khoá SQL. Dùng allowlist dạng ánh xạ: input là khoá, server tra ra identifier.
ORM có chống được injection hoàn toàn không?Chỉ ở vị trí giá trị. Raw method, vị trí identifier và object filter do client gửi là trách nhiệm ứng dụng — TypeORM ghi thẳng trong SECURITY.md.
Escape input có đủ không?Không. OWASP xếp vào "strongly discouraged": phụ thuộc interpreter, chế độ, charset, ngữ cảnh; dễ sót. Chỉ dùng khi không còn cách nào.
Validate input hay encode output?Cả hai, vai trò khác nhau: validate ở điểm nhận (kiểu, miền giá trị nhỏ), encode/bind ở điểm dùng theo đúng interpreter. Encode ở điểm dùng mới là phòng thủ chính cho văn bản tự do.
Stored XSS khác reflected, DOM-based?Stored: lưu rồi hiện cho người khác. Reflected: phản xạ ngay từ request. DOM-based: xảy ra hoàn toàn trong JS phía client, server không thấy — phải soi sink DOM.
Có CSP rồi còn cần escape không?Cần. CSP là lớp thứ hai, OWASP phản đối chỉ dựa vào CSP; CSP lỏng (unsafe-inline) gần như không chặn gì.
Gọi ffmpeg từ PHP/Node an toàn thế nào?Mảng tham số, không shell (Process mảng, execFile); tên file do server sinh; timeout; container ít quyền. Truyền chuỗi cho Process của Laravel là chạy qua shell.
NoSQL có bị injection không?Có, dạng khác: object toán tử thay cho giá trị vô hướng. Validate kiểu bằng DTO trước khi dựng filter.
Least privilege giúp gì nếu đã tham số hoá?Giới hạn thiệt hại khi một chỗ bị sót (raw cũ, plugin bên thứ ba): user báo cáo chỉ đọc, user app không DDL.

10. Chưa verify và Nguồn

Chưa verify

Bài này không có lab. Ngoài nguyên tắc chung đó, các điểm sau đặc biệt cần lab hoặc đối chiếu thêm mới khẳng định được:

  1. Hành vi Laravel suy ra từ đọc mã nguồn nhánh 13.x (29/10/2026), chưa chạy: ATTR_EMULATE_PREPARES => false trong Connector (riêng pdo_pgsql đã có lab ở bài Khi plan sai); orderBy ném InvalidArgumentException với hướng lạ; whereIntegerInRaw ép (int); PendingProcess dùng shell khi command là chuỗi; e() trả nguyên Htmlable. Chưa đối chiếu tag phiên bản Laravel 13 cụ thể.
  2. Cách Laravel wrap tên cột và các trường hợp đặc biệt (JSON path, alias): không đọc, chỉ nêu là "chi tiết cài đặt, đừng dựa vào".
  3. PostgreSQL extended query protocol (Parse/Bind/Execute) và PL/pgSQL EXECUTE ... USING, format('%I'): theo docs PostgreSQL, không chạy lại trên container của repo trong bài này (series doc-only).
  4. Ký tự escape mặc định của LIKE là dấu gạch chéo ngược trên MySQL và PostgreSQL: theo hiểu biết về docs hai hệ quản trị, chưa tra lại.
  5. Moodle: emulate_bound_params, sql_like, sql_like_escape, get_in_or_equal đọc từ mã nguồn moodle_database.php và mysqli_native_moodle_database.php (bản tải 27/10/2026, nhánh chính); khuyến nghị đặt charset qua API driver thay vì SET NAMES; hàm output s()/format_string()/format_text() và Mustache escape; Moodle lọc HTML lúc render hay lúc lưu; bộ tính công thức điểm của Moodle không dùng eval; mô hình custom reports — đều chưa verify.
  6. React chặn URL javascript: trong href ở bản hiện tại hay chỉ cảnh báo: chưa verify (bài khuyên tự kiểm scheme bất kể). DOMPurify chạy phía server trong Next.js: chưa verify.
  7. Express res.send(string) đặt text/html; Node vm không phải cơ chế bảo mật; PHP ldap_escape; framework từ chối header có CR/LF: theo hiểu biết về docs, chưa tra lại qua Context7.
  8. HTML Purifier / mews/purifier, Psalm taint analysis, Semgrep rule Laravel/Express, CodeIgniter 3 binding: nêu ở mức khái niệm, chưa đối chiếu docs phiên bản hiện tại.
  9. MySQL read_only không áp dụng cho user có quyền quản trị: theo hiểu biết về docs MySQL, chưa tra lại.
  10. Sự cố ở mục 8.1: mô tả theo CISA, NVD, ICO và mô tả công khai của tác giả Samy; con số (400.000 bảng, hơn một triệu hồ sơ) không tự kiểm.
  11. CWE-73 bị bỏ khỏi A05 năm 2025 chuyển sang mục nào: chưa đối chiếu.
  12. Các giả định * ở mục 7: portal NestJS đọc/ghi trực tiếp bảng Moodle và dùng thư viện query nào, user MySQL của replica/portal, Moodle dboptions, nội dung editor hiển thị lại ở portal, loại "chấm điểm" của worker, MongoDB/MinIO dùng cho việc gì — CV không nêu.

Nguồn

Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 4 — A04 Cryptographic Failures · Bài 5 — A05 Injection (bài này) · Tiếp: Bài 6 — A06 Insecure Design →