Bài 10 — A10:2025 Mishandling of Exceptional Conditions: lỗi thì đóng chứ không mở, xử lý lỗi một chỗ, rollback trọn vẹn, dọn tài nguyên, và không có gì là vô hạn
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 9 — A09 Security Logging and Alerting Failures · Bài 10 — A10 Mishandling of Exceptional Conditions (bài này, bài cuối của series)
Chín bài trước hỏi "code có làm đúng việc khi mọi thứ bình thường không". A10 hỏi câu ngược lại: khi một thứ không bình thường xảy ra —
Redis không trả lời, MySQL primary đang failover, request thiếu một trường, file upload lớn gấp mười lần dự kiến, một promise bị reject mà không ai
await — thì code đi tiếp theo nhánh nào? Nhánh đó có được thiết kế, hay chỉ là "cái gì xảy ra thì xảy ra"? OWASP tóm lại bằng một câu rất
đáng nhớ: "Any time an application is unsure of its next instruction, an exceptional condition has been mishandled." (bất cứ khi nào ứng dụng
không chắc lệnh tiếp theo của mình là gì, một tình huống bất thường đã bị xử lý sai).
Câu interviewer muốn nghe ở mức Senior/Tech Lead không phải "em có try/catch" mà là bốn ý: (1) với mỗi phụ thuộc, em đã quyết định trước
nó hỏng thì hệ thống mở (fail open) hay đóng (fail closed), và lý do; (2) lỗi được xử lý ở một chỗ, theo một cách, trả cho người dùng thông
điệp chung kèm mã tra cứu còn chi tiết nằm ở log; (3) một thao tác nhiều bước hỏng giữa chừng thì không để lại trạng thái nửa vời, và
tài nguyên (connection, lock, file tạm) luôn được trả; (4) mọi thứ có giới hạn — timeout, số lần retry, kích thước input, hàng đợi — vì
"không giới hạn" chính là cửa cho DoS và hoá đơn cloud bất thường.
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 Top 10:2025, OWASP Error Handling Cheat Sheet, docs Laravel 13 / NestJS / TypeORM / Node.js tra qua Context7,config-dist.phpcủa MoodleMOODLE_405_STABLEđọc trực tiếp, báo cáo sự cố công khai của Google Cloud và Cloudflare) 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 ở mục 10. Hai hành vi được trích từ lab của bài khác trong repo (đã chạy thật ở đó) và có ghi rõ link. Bài tập trung vào phòng thủ: mỗi dạng lỗi được giải thích ở mức "giả định nào trong code sai và hệ quả là gì".
Đọc kèm trong repo — bài này không nhắc lại:
Rate limit dùng chung nhiều server (lab: Redis chết thì throttle của Laravel trả 500 cho mọi người,
Redis treo thì request kẹt; middleware fail-open có cảnh báo),
Vận hành rate limit — retry + jitter, ngân sách retry,
Microtask trong Node.js (mục 5.2: lab một promise reject không ai bắt làm chết cả process trên Node 22),
Scale out queue worker (worker bị kill giữa chừng, job chạy lại),
Idempotency key ở Redis và Chống double-submit,
Gửi mail at-least-once.
Bài 10 đứng trên các bài đó và chỉ thêm góc bảo mật: nhánh lỗi nào mở cửa cho kẻ tấn công, lộ thông tin, hay để lại trạng thái sai.
1. Mục này là gì trong 2025
1.1 Định nghĩa: ba thất bại — không ngăn, không nhận ra, phản ứng sai
1. OWASP định nghĩa: "Mishandling exceptional conditions in software happens when programs fail to prevent, detect, and respond to unusual and unpredictable situations, which leads to crashes, unexpected behavior, and sometimes vulnerabilities." Ba động từ đó là ba thất bại tách biệt, và một hệ thống có thể trượt ở bất kỳ cái nào:
- Không ngăn (prevent) — để tình huống bất thường xảy ra mà lẽ ra chặn được từ cửa: không validate kiểu/kích thước input, không đặt giới hạn
tài nguyên, không timeout. Ví dụ: API nhận
page_sizemà không có trần, một request xin một triệu dòng. - Không nhận ra (detect) — tình huống đã xảy ra nhưng code không biết: hàm trả
false/nullbáo lỗi mà không ai kiểm (CWE-252 Unchecked Return Value),catchrỗng nuốt lỗi (CWE-390), promise không aiawait. - Phản ứng sai (respond) — nhận ra rồi nhưng làm sai: cho qua thay vì từ chối (CWE-636 Failing Open), trả nguyên stack trace cho người dùng (CWE-209), rollback một nửa giao dịch, quên trả lock/connection (CWE-460).
2. Nguyên nhân OWASP liệt kê: "missing, poor, or incomplete input validation, or late, high level error handling instead at the functions where they occur, or unexpected environmental states such as memory, privilege, or network issues, inconsistent exception handling, or exceptions that are not handled at all". Chú ý vế thứ hai: xử lý lỗi muộn, ở tầng cao, thay vì tại hàm nơi lỗi xảy ra. Đây là điểm sẽ có hai trường phái (mục 5.3) — một bên nói "bắt tại chỗ", bên kia nói "để lỗi nổi lên handler chung" — và câu trả lời tốt là phân biệt loại lỗi chứ không chọn một phe.
3. Hệ quả OWASP nêu trải trên cả ba chữ CIA: "logic bugs, overflows, race conditions, fraudulent transactions, or issues with memory, state, resource, timing, authentication, and authorization". Tức là A10 không phải "lỗi chất lượng code" thuần tuý — nó là con đường mà một lỗi vận hành (Redis chậm) biến thành lỗi bảo mật (ai cũng được vào).
flowchart TD
E["Tình huống bất thường<br/>(input lạ, phụ thuộc chết, hết tài nguyên)"] --> P{"1. Có ngăn từ cửa không?<br/>validate, giới hạn, timeout"}
P -->|"không"| D{"2. Code có nhận ra không?<br/>kiểm giá trị trả về, không nuốt exception"}
P -->|"có: từ chối sớm, 4xx"| OK1["An toàn"]
D -->|"không"| U["Trạng thái không xác định<br/>chạy tiếp như thể không có gì"]
D -->|"có"| R{"3. Phản ứng thế nào?"}
R -->|"fail closed, rollback,<br/>dọn tài nguyên, log"| OK2["An toàn"]
R -->|"cho qua, lộ chi tiết,<br/>rollback một nửa"| V["Lỗ hổng"]
U --> V
Cách nói khi phỏng vấn: "OWASP chia A10 thành ba thất bại nối tiếp. Thứ nhất là không ngăn từ cửa: không kiểm kiểu, kích thước, không có timeout hay giới hạn. Thứ hai là có chuyện rồi mà code không biết: bỏ qua giá trị báo lỗi, nuốt exception, quên chờ promise. Thứ ba là biết rồi nhưng phản ứng sai: cho qua thay vì từ chối, lộ chi tiết lỗi, hay rollback nửa vời. Em review code theo đúng ba câu hỏi đó cho mỗi nhánh lỗi."
1.2 Mục mới — gom từ "code quality" và đứng sát X01 Resilience
1. A10:2025 là mục mới hoàn toàn, không có ở 2021. OWASP viết: "This category has some CWEs that were previously associated with poor code
quality. That was too general for us; in our opinion, this more specific category provides better guidance." Năm 2021 các CWE này nằm rải rác trong phần
"Next Steps" dưới cái nhãn rất rộng "Code Quality issues" (xem bảng đối chiếu ở Bài 0). Ý nghĩa thực tế: trước đây khi
review thấy catch rỗng, người ta gọi là "code bẩn" và bỏ qua; nay nó có tên trong Top 10 và có thể đưa vào yêu cầu bảo mật của dự án.
2. A10 đứng sát X01:2025 Lack of Application Resilience (thiếu khả năng chịu lỗi — tên mới của nhóm Denial of Service, bị đẩy xuống phần Next Steps). OWASP ghi ở X01 rằng điểm của nó "was very close with A10:2025"; phép tính lại ở Bài 0 cho A10 = 530,96 và X01 = 522,02. Hai mục chồng lấn ở chỗ "không có giới hạn tài nguyên": A10 nhìn từ góc xử lý tình huống (lỗi xảy ra thì có trả tài nguyên không), X01 nhìn từ góc khả năng chịu tải. OWASP đưa luôn lời khuyên về giới hạn vào A10: "Nothing in information technology should be limitless".
1.3 Bảng điểm chính thức
| Chỉ số (top10.owasp.org, A10:2025) | Giá trị | Đọc thế nào |
|---|---|---|
| CWEs Mapped | 24 | Rộng — từ lộ thông tin lỗi tới null dereference tới fail open |
| Max Incidence Rate | 20,67% | Có CWE xuất hiện ở hơn 1/5 ứng dụng được kiểm tra |
| Avg Incidence Rate | 2,95% | Trung bình qua 24 CWE thấp hơn nhiều — vài CWE chiếm phần lớn |
| Max / Avg Coverage | 100% / 37,95% | Có CWE được mọi nguồn dữ liệu kiểm, nhưng trung bình chưa tới 40% — nhiều CWE ở đây công cụ kiểm ít |
| Avg Weighted Exploit / Impact | 7,11 / 3,81 | Khai thác dễ ở mức trung bình; impact thấp hơn A07 (4,44) hay A03 (5,23) |
| Total Occurrences | 769.581 | Nhiều hơn A06 (729.882) và A02 (719.084) |
| Total CVEs | 3.416 | Số CVE gắn với các CWE này |
Điểm đáng nói khi phỏng vấn: Avg Coverage 37,95% nghĩa là phần lớn CWE trong nhóm này ít được công cụ tự động kiểm — đúng với trực giác:
SAST bắt được catch rỗng, nhưng không biết nhánh "Redis timeout thì cho qua" là sai hay là chủ đích. Phần lớn A10 phải bắt bằng review và bằng
test có chủ động gây lỗi (mục 6).
1.4 Gom 24 CWE theo cơ chế
Học thuộc 24 số CWE là vô ích; gom theo giả định sai trong code thì nhớ được và ánh xạ thẳng sang mục 3:
| Nhóm cơ chế | CWE | Giả định sai | Mục |
|---|---|---|---|
| Lỗi thì cho qua | 636 Not Failing Securely, 280/274 Improper Handling of Insufficient Permissions/Privileges | "Kiểm tra không chạy được thì coi như đạt" | 3.1 |
| Lỗi lộ thông tin | 209, 550 (thông điệp lỗi chứa thông tin nhạy cảm), 215 (thông tin debug), 756 (thiếu trang lỗi tuỳ biến) | "Thông điệp lỗi chỉ developer đọc" | 3.2 |
| Input bất thường | 234 thiếu tham số, 235 thừa tham số, 476 NULL dereference, 369 chia cho 0, 754 không kiểm tình huống bất thường, 478 thiếu nhánh default, 484 thiếu break | "Input luôn đủ và đúng dạng" | 3.3 |
| Lỗi không được nhận ra | 252 không kiểm giá trị trả về, 390 phát hiện lỗi mà không hành động, 391 không kiểm điều kiện lỗi, 394 mã trả về bất ngờ, 248 exception không ai bắt | "Gọi hàm là thành công" | 3.4, 3.8 |
| Bắt/ném quá rộng | 396 catch Exception chung, 397 throws Exception chung | "Lỗi nào cũng như nhau" | 3.4 |
| Trạng thái và tài nguyên | 460 dọn dẹp sai khi có exception, 703/754/755 (nhóm cha: kiểm/xử lý tình huống bất thường) | "Lỗi xảy ra thì phần đã làm tự biến mất" | 3.5, 3.6 |
1.5 Ranh giới với A02, A09, A06 và A01
1. A02 Security Misconfiguration có dấu hiệu "Error handling reveals stack traces". Cách phân: bật APP_DEBUG=true trên
production là cấu hình sai (A02, xem Bài 2); code tự ghép $e->getMessage() vào response
là xử lý lỗi sai (A10, CWE-209). Cùng một triệu chứng, hai chỗ sửa khác nhau.
2. A09 Logging & Alerting: lỗi bị nuốt thì không được ghi — Bài 9 mục 3.7 đã nói chỗ giao nhau. A10 lo nhánh code sau lỗi; A09 lo dấu vết và cảnh báo. OWASP A10 còn gợi ý: lỗi giống hệt nhau lặp quá một tần suất nên được gộp thành thống kê để không làm ngập log — đó là phần A09.
3. A06 Insecure Design: "chưa từng quyết định hỏng thì mở hay đóng" là lỗ hổng thiết kế — thiếu yêu cầu. Viết catch { return true; }
là lỗi cài đặt. Ở mức Tech Lead, A10 tốt bắt đầu từ A06: bảng "phụ thuộc → hành vi khi hỏng" nằm trong tài liệu thiết kế (mục 5.2).
4. A01 Broken Access Control: kiểm quyền fail open là A10 về cơ chế nhưng là A01 về hậu quả. Bài 1 nói "kiểm quyền thế nào"; bài này nói "kiểm quyền không chạy được thì sao".
2. Bài toán gốc — vì sao nhánh lỗi luôn là nhánh tệ nhất của code
1. Nhánh lỗi gần như không được chạy. Đường thành công chạy hàng triệu lần mỗi ngày, được test tự động, được người dùng "test" liên tục. Nhánh
catch (RedisException) có thể chưa chạy lần nào kể từ ngày viết. Google Cloud viết đúng điều này trong báo cáo sự cố 12/06/2025: đoạn code lỗi
"was never exercised during this rollout due to needing a policy change that would trigger the code" — nó qua toàn bộ quy trình rollout từng vùng mà không
ai phát hiện, vì chưa có dữ liệu nào đi vào nhánh đó (chi tiết ở mục 8.1). Code không chạy thì không có bằng chứng nó đúng.
2. Người viết nhánh lỗi thường đang vội. Nhánh lỗi được viết khi IDE báo "unhandled exception" hoặc khi test đỏ — mục tiêu lúc đó là làm cho
lỗi biến mất, không phải làm cho hệ thống an toàn. Cách nhanh nhất để lỗi biến mất là catch rồi trả giá trị mặc định — và giá trị mặc
định "tiện" nhất thường là giá trị cho qua (true, mảng rỗng nghĩa là "không có hạn chế", null nghĩa là "không có quy tắc nào").
3. Ngôn ngữ và framework có nhiều "kênh lỗi" song song. PHP có exception, có giá trị trả về báo lỗi (false của fopen,
null của json_decode khi không bật JSON_THROW_ON_ERROR), có warning/notice; Node có exception đồng bộ, promise reject, sự kiện 'error' của EventEmitter/stream, và callback
(err, data). Mỗi kênh cần một cách bắt khác nhau; quên một kênh là có lỗi không ai nhận ra. Ví dụ worked: một hàm NestJS gọi
this.mailer.send() mà không await — nếu nó reject, không có try/catch nào xung quanh bắt được, vì lúc nó reject thì hàm đã return từ
lâu; với Node ≥ 15 mặc định là --unhandled-rejections=throw, tức là process chết (lab ở bài Microtask mục
5.2: một reject không ai bắt làm chết server trên Node 22, mọi request đang xử lý khác cũng mất).
4. Hệ phân tán làm "lỗi" thành ba trạng thái chứ không phải hai. Gọi một service qua mạng có thể: thành công, thất bại, hoặc không biết (timeout — request có thể đã được xử lý, chỉ là câu trả lời không về). Code viết theo tư duy đơn máy chỉ có hai nhánh, nên nhánh "không biết" bị nhét vào "thất bại" rồi retry — với thao tác không idempotent (trừ tiền, chấm điểm, gửi mail) đó là cách tạo bản ghi trùng. Kịch bản #3 của OWASP (chuyển tiền bị cắt mạng giữa chừng) chính là chỗ này.
5. Không ai sở hữu "hành vi khi hỏng". Product owner viết yêu cầu cho đường thành công; QA test theo yêu cầu đó; developer quyết định nhánh lỗi một mình, trong lúc code, không ghi lại. Kết quả là cùng một hệ thống, module A fail closed khi Redis chết, module B fail open, module C treo 60 giây — không vì lý do gì cả. OWASP nhắm thẳng vào điều này: "One application should not multiple functions for handling exceptional conditions, it should be performed in one place, the same way each time", và rộng hơn: "your entire organization should handle exceptional conditions in the same way".
6. Ví dụ số (minh hoạ): một API có 40 endpoint, mỗi endpoint trung bình gọi 3 phụ thuộc (DB, Redis, một service nội bộ), mỗi phụ thuộc có ít nhất 2 kiểu hỏng (từ chối kết nối, timeout). Đó là 40 × 3 × 2 = 240 nhánh lỗi. Nếu không có handler tập trung và bảng quyết định chung, team đang có 240 quyết định ngầm do từng người viết lúc vội. Có bảng chung (mục 5.2) thì còn khoảng 3 × 2 = 6 quyết định, ghi trong ADR, review một lần.
Cách nói khi phỏng vấn: "Nhánh lỗi là nhánh tệ nhất của code vì ba lý do: nó gần như không bao giờ chạy nên không có bằng chứng nó đúng; nó thường được viết lúc vội với mục tiêu làm lỗi biến mất, và giá trị mặc định tiện nhất thường là giá trị cho qua; và không ai sở hữu nó — yêu cầu chỉ mô tả đường thành công. Một API bốn mươi endpoint, mỗi endpoint ba phụ thuộc, mỗi phụ thuộc hai kiểu hỏng là hai trăm bốn mươi quyết định ngầm. Việc của tech lead là gom chúng về vài quyết định có chủ đích, viết thành văn bản, và cài đặt ở một chỗ."
3. Cơ chế — mỗi dạng sai ở giả định nào
Mỗi mục dưới đây theo cùng một khuôn: giả định sai trong code là gì, hệ quả cụ thể, ví dụ, và một sơ đồ nhỏ. Mẫu code sai/đúng cho từng dạng ở mục 4.
3.1 Fail open (CWE-636) — "kiểm tra không chạy được thì coi như đạt"
1. Định nghĩa. CWE-636 Not Failing Securely: khi gặp lỗi, hệ thống rơi về trạng thái kém an toàn hơn trạng thái lẽ ra phải có — thường là bỏ qua một bước kiểm tra. Ngược lại, fail closed (còn gọi fail secure) là khi không chắc thì từ chối.
2. Hình dạng điển hình trong backend. Một middleware kiểm "tài khoản có bị khoá không" đọc danh sách khoá từ Redis; Redis timeout; nhánh
catch trả "không bị khoá" để "đừng làm phiền người dùng". Hệ quả: đúng lúc Redis có vấn đề — điều kẻ tấn công có thể góp phần gây ra bằng
cách tạo tải — mọi tài khoản bị khoá đều đăng nhập được. Các biến thể cùng gốc:
- Kiểm quyền gọi một service chính sách (policy decision point) qua mạng; service chậm → mặc định "allow".
- Xác minh chữ ký webhook ném exception vì header thiếu;
catchghi warning rồi xử lý tiếp payload (ví dụ ở Bài 9 mục 3.7). - Đọc cấu hình feature flag "tắt chức năng X cho tenant Y" lỗi → mặc định "bật".
- CWE-280/274: thao tác cần quyền (đổi quyền file, hạ quyền process, ghi vào thư mục chỉ admin) thất bại vì thiếu quyền, code không kiểm kết quả và chạy tiếp như thể đã thành công — ví dụ không hạ được quyền mà vẫn xử lý input của người dùng với quyền cao.
3. Fail open không phải lúc nào cũng sai — đây là chỗ phân biệt Senior. Rate limiter fail open khi Redis chết là lựa chọn phổ biến và có lý:
limiter là lớp bảo vệ tài nguyên, không phải lớp quyết định quyền; chặn hết mọi người vì limiter hỏng nghĩa là tự gây sự cố toàn hệ thống (lab ở
bài Rate limit phân tán: Redis tắt thì throttle mặc định của Laravel trả 500 cho mọi request; middleware tự viết
fail-open có cảnh báo thì request vẫn qua). Google Cloud sau sự cố 06/2025 còn chọn hướng ngược cho kiểm quota: "We will modularize Service Control's
architecture, so the functionality is isolated and fails open". Tiêu chí phân định ở mục 5.2: câu hỏi không phải "mở hay đóng" mà là "bước kiểm tra này bảo vệ
cái gì, và nếu cho qua sai thì thiệt hại có đảo ngược được không".
flowchart TD
R["Request cần kiểm tra"] --> C{"Gọi phụ thuộc kiểm tra<br/>(Redis, policy service, IdP)"}
C -->|"trả lời: cho phép"| A["Cho qua"]
C -->|"trả lời: từ chối"| D["403"]
C -->|"timeout / lỗi kết nối"| K{"Bước này bảo vệ gì?"}
K -->|"quyền, xác thực, chữ ký,<br/>tiền, dữ liệu cá nhân"| FC["Fail closed: 503 + log + metric"]
K -->|"tài nguyên (rate limit),<br/>tính năng phụ (gợi ý, thống kê)"| FO["Fail open CÓ CHỦ ĐÍCH:<br/>cho qua + log + cảnh báo"]
Cách nói khi phỏng vấn: "Fail open là khi kiểm tra không chạy được thì hệ thống coi như đạt — ví dụ Redis chứa danh sách tài khoản bị khoá timeout, và code cho đăng nhập luôn. Nguy hiểm vì đúng lúc hạ tầng có vấn đề, mà kẻ tấn công có thể góp phần tạo ra, thì cửa mở. Nhưng em không nói fail open lúc nào cũng sai: rate limiter bảo vệ tài nguyên, chặn hết khi Redis chết là tự gây sự cố, nên nhiều team cho qua có cảnh báo. Em phân theo một câu: bước kiểm tra này bảo vệ gì, và cho qua sai thì có sửa lại được không."
3.2 Lỗi lộ thông tin (CWE-209, 550, 215, 756) — "thông điệp lỗi chỉ developer đọc"
1. Cơ chế. Exception mang theo rất nhiều thứ hữu ích cho developer: câu SQL gốc, tên bảng/cột, đường dẫn file trên server, phiên bản framework, tên host database, đôi khi giá trị biến (kể cả mật khẩu kết nối trong stack trace của hàm nhận tham số đó). Khi exception được render thẳng ra response, tất cả thành thông tin trinh sát (reconnaissance). OWASP Error Handling Cheat Sheet mở đầu đúng bằng ý này: "an attack always begins with a Reconnaissance phase in which the attacker will try to gather as much technical information (often name and version properties) as possible about the target".
2. Kịch bản #2 của OWASP: "Sensitive data exposure via improper handling or database errors that reveals the full system error to the user. The attacker continues to force errors in order to use the sensitive system information to create a better SQL injection attack. The sensitive data in the user error messages are reconnaissance." Điểm cần nói: thông điệp lỗi chi tiết không tự nó là lỗ hổng SQL injection, nhưng nó biến một lỗ hổng "mù" (khó khai thác) thành lỗ hổng "có phản hồi" (dễ khai thác), và nó cho biết chính xác phiên bản nào để tra CVE. Phòng thủ SQL injection thật sự ở Bài 5.
3. Bốn CWE, bốn đường rò:
- CWE-209 — thông điệp lỗi do code tạo ra chứa thông tin nhạy cảm:
return response()->json(['error' => $e->getMessage()]). - CWE-550 — thông điệp lỗi do server/framework tạo: trang lỗi mặc định của web server ghi phiên bản, trang debug của framework.
- CWE-215 — code debug còn sót: route
/debug,dd(),console.log(req.body), headerX-Debug-Query. - CWE-756 — thiếu trang lỗi tuỳ biến, nên rơi về trang mặc định (thường lộ thông tin như CWE-550).
4. Kênh rò ít ai để ý: (a) khác biệt giữa các thông điệp lỗi — "email không tồn tại" khác "sai mật khẩu" là lộ danh sách tài khoản (xem Bài 7); (b) khác biệt mã trạng thái — 404 cho tài nguyên không có, 403 cho tài nguyên có nhưng không được xem là lộ sự tồn tại (nhiều hệ thống trả 404 cho cả hai); (c) khác biệt thời gian — nhánh lỗi sớm trả nhanh hơn nhánh chạy hết; (d) dữ liệu lỗi gửi sang bên thứ ba (Sentry, APM) — chi tiết không lên response nhưng lên một hệ thống khác với quyền đọc khác.
5. Quy tắc đúng: response cho người dùng có (a) mã trạng thái đúng nhóm (4xx do client, 5xx do server — Cheat Sheet: "use 4xx error codes for requests that are due to an error on the part of the HTTP client ... and use 5xx to indicate errors that are triggered on server side"), (b) thông điệp chung dễ hiểu, (c) một mã tra cứu (error id / request id) để người dùng báo lại và người trực tìm đúng dòng log. Chi tiết đầy đủ chỉ ở log phía server. Cheat Sheet gợi ý định dạng RFC 7807 Problem Details (đã được thay bằng RFC 9457 năm 2023 — theo hiểu biết chung, chưa đọc trong bài này).
flowchart LR
X["Exception: QueryException<br/>SQL, tên bảng, host DB"] --> H["Handler lỗi tập trung"]
H --> L["Log server: đầy đủ chi tiết<br/>+ error_id + request_id"]
H --> S["Sentry/APM: đã lọc PII"]
H --> U["Response: 500<br/>thông điệp chung + error_id"]
Cách nói khi phỏng vấn: "Exception chứa câu SQL, tên bảng, đường dẫn file, phiên bản thư viện — rất quý cho developer và cũng rất quý cho kẻ đang dò hệ thống. OWASP gọi đó là thông tin trinh sát: nó không tự là lỗ hổng injection, nhưng biến lỗ hổng khó khai thác thành dễ khai thác. Quy tắc của em: người dùng nhận mã trạng thái đúng nhóm, một câu chung và một mã tra cứu; chi tiết chỉ nằm ở log phía server, và dữ liệu gửi sang công cụ theo dõi lỗi cũng phải lọc."
3.3 Input bất thường (CWE-234, 235, 476, 369, 754, 478, 484) — "input luôn đủ và đúng dạng"
1. Thiếu tham số (CWE-234) và NULL dereference (CWE-476) thường là một cặp: request không có trường course_id, code đọc
$request->course_id được null, truyền tiếp vào Course::find(null) được null, rồi gọi ->students trên
null → lỗi 500. Lỗi 500 tự nó chỉ là khó chịu; nguy hiểm hơn là khi null được hiểu là "không có điều kiện": ví dụ một query builder bỏ qua
điều kiện where('tenant_id', $tenantId) khi $tenantId là null (do code tự viết kiểu ->when($tenantId, ...)) — thiếu
tham số biến thành đọc dữ liệu của mọi tenant. Google Cloud 06/2025 là CWE-476 ở quy mô toàn cầu: dữ liệu chính sách có trường trống, code đi vào nhánh
null pointer, binary crash lặp lại trên mọi vùng.
2. Thừa tham số (CWE-235): request gửi thêm trường mà code không mong đợi — role, is_admin, tenant_id — và code gán
hàng loạt vào model (mass assignment). Đây là nơi A10 chạm A01: cách chặn là chỉ nhận trường đã khai báo (Laravel
$request->validated(), NestJS ValidationPipe với whitelist + forbidNonWhitelisted). Kiểu tham số trùng lặp
(?id=1&id=2) cũng thuộc nhóm này: framework khác nhau chọn giá trị đầu, giá trị cuối, hoặc tạo mảng — code mong một chuỗi mà nhận mảng thì đi vào
nhánh không ai nghĩ tới.
3. Sai kiểu và sai kích thước (CWE-754): số âm cho số lượng, chuỗi cho số, số rất lớn, mảng một triệu phần tử, file vài GB, JSON lồng sâu.
Ví dụ worked: endpoint xuất báo cáo nhận per_page; người dùng gửi 1.000.000; mỗi dòng ~1 KB → 1 GB dữ liệu đọc vào bộ nhớ của một worker PHP-FPM có
memory_limit 256 MB → fatal error; gửi lặp 20 lần song song → 20 worker chết hoặc treo, pool cạn. Không có giới hạn là A10 (không ngăn) và X01
(không chịu tải) cùng lúc.
4. Chia cho 0 (CWE-369): tính tỉ lệ hoàn thành = số bài đã làm / tổng số bài; khoá học mới có 0 bài → PHP 8 ném DivisionByZeroError
(với / trên số nguyên và số thực, theo PHP manual — chưa verify trong bài), JavaScript trả Infinity hoặc NaN và chạy tiếp.
Hai ngôn ngữ, hai kiểu hỏng: một bên ồn ào, một bên im lặng — NaN lọt vào điều kiện so sánh thì mọi phép so sánh đều false, có thể làm một
kiểm tra ngưỡng không bao giờ kích hoạt.
5. Thiếu nhánh default (CWE-478) và thiếu break (CWE-484): switch ($role) có nhánh cho student, teacher,
admin; thêm vai trò assistant sáu tháng sau, không ai sửa switch → rơi khỏi mọi nhánh, biến quyền giữ giá trị khởi tạo (có khi là
"toàn quyền" nếu ai đó khởi tạo lười). Ngôn ngữ hiện đại có công cụ chống: PHP 8 match ném UnhandledMatchError khi không có nhánh khớp; TypeScript
kiểm tra "exhaustive" bằng kiểu never. Thiếu break (rơi xuống nhánh dưới) là lỗi của switch trong PHP/JS; match không có rơi nhánh.
flowchart TD
I["Input đến"] --> V{"Validate tại cửa:<br/>có đủ, đúng kiểu, trong khoảng,<br/>không thừa trường, đúng kích thước"}
V -->|"sai"| E["422 + danh sách trường sai<br/>(không lộ nội bộ)"]
V -->|"đúng"| T["Dữ liệu đã kiểu hoá:<br/>int, enum, DTO"]
T --> B["Logic nghiệp vụ<br/>match/never: mọi giá trị đều có nhánh"]
B -->|"giá trị không lường trước"| X["Ném lỗi rõ ràng<br/>KHÔNG rơi về mặc định"]
Cách nói khi phỏng vấn: "Input bất thường có bốn dạng: thiếu, thừa, sai kiểu và quá cỡ. Thiếu tham số hay dẫn tới null, và null nguy hiểm nhất khi code hiểu nó là 'không có điều kiện', ví dụ bỏ qua lọc theo tenant. Thừa tham số là cửa của mass assignment. Quá cỡ thì một request xin một triệu dòng có thể làm chết worker. Em chặn tất cả ở cửa bằng validate theo danh sách trường cho phép, có giới hạn kích thước, và trong logic thì dùng cấu trúc buộc phải xử lý mọi giá trị, để giá trị lạ ném lỗi rõ ràng thay vì rơi về mặc định."
3.4 Lỗi không được nhận ra (CWE-252, 390, 391, 394, 396, 397) — "gọi hàm là thành công"
1. Không kiểm giá trị trả về (CWE-252, 391). Nhiều hàm báo lỗi bằng giá trị trả về chứ không bằng exception. Trong PHP: fopen,
file_put_contents trả false; json_decode trả null khi JSON hỏng (trừ khi dùng cờ JSON_THROW_ON_ERROR);
Cache::lock(...)->get() trả false khi không lấy được lock; Cache::add() trả false khi key đã có. Ví dụ worked: job
"chấm điểm" lấy lock để không chấm hai lần; code gọi $lock->get() nhưng không kiểm kết quả → hai worker cùng chấm → điểm bị ghi hai lần hoặc ghi đè.
Lock tồn tại nhưng vô dụng.
2. Phát hiện mà không hành động (CWE-390). catch (Exception $e) {} hoặc .catch(() => {}). Hệ quả đôi: hành vi sai
(chạy tiếp như thành công) và không dấu vết (A09). Biến thể tinh vi hơn: catch chỉ log rồi tiếp tục — có dấu vết nhưng hành vi vẫn sai.
3. Mã trả về bất ngờ (CWE-394). Gọi API ngoài, kiểm status == 200 là thành công, còn lại là lỗi — nhưng API trả 202 Accepted
(chưa xử lý xong) hoặc 200 với body {"success": false}. Hoặc ngược lại: HTTP client không ném exception với 4xx/5xx theo mặc định (Laravel HTTP
client không ném trừ khi gọi ->throw() — theo docs Laravel; fetch của Node cũng không reject với 4xx/5xx — hành vi chuẩn của Fetch API),
nên code đọc $response->json('data') từ một trang lỗi và được null.
4. Bắt/ném quá rộng (CWE-396, 397). catch (\Throwable $e) ở giữa logic nghiệp vụ gom chung lỗi "không tìm thấy bản ghi" (bình thường,
nên trả 404) với "mất kết nối DB" (bất thường, nên 503 và báo động) và "lỗi lập trình" (TypeError, nên nổ ra để sửa). Xử lý chung một kiểu thì chắc chắn
sai với ít nhất hai trong ba. Bắt rộng chỉ đúng ở một chỗ: handler toàn cục ở biên ngoài cùng (mục 4.1), nơi việc duy nhất là log và trả thông điệp chung.
flowchart TD
CALL["Gọi hàm / API"] --> K{"Kênh báo lỗi là gì?"}
K -->|"exception"| E1{"Bắt đúng loại<br/>mình xử lý được?"}
K -->|"giá trị trả về<br/>(false, null, status)"| E2{"Có kiểm không?"}
E1 -->|"có"| H["Xử lý có nghĩa:<br/>thử lại / giá trị thay thế có chủ đích / từ chối"]
E1 -->|"không"| UP["Để nổi lên handler chung"]
E2 -->|"có"| H
E2 -->|"không"| BAD["Chạy tiếp như thành công<br/>(CWE-252)"]
H -->|"catch rỗng"| BAD2["Nuốt lỗi (CWE-390)"]
Cách nói khi phỏng vấn: "Lỗi không được nhận ra có hai kênh. Một là hàm báo lỗi bằng giá trị trả về — lấy lock không được thì trả false — mà code không kiểm, nên lock tồn tại mà vô dụng. Hai là exception bị bắt rồi bỏ qua, hoặc bắt quá rộng, gom chung lỗi 'không tìm thấy' với 'mất kết nối database' rồi xử lý một kiểu. Quy tắc của em: chỉ bắt loại lỗi mình thực sự xử lý được ở chỗ đó; còn lại để nổi lên handler chung ở biên ngoài cùng."
3.5 Trạng thái nửa vời — giao dịch nhiều bước hỏng giữa chừng
1. Kịch bản #3 của OWASP: "Imagine the transaction order was: debit user account, credit destination account, log transaction. If the system doesn't properly roll back the entire transaction (fail closed) when there is an error part way through, the attacker could potentially drain the user's account, or possibly a race condition that allows the attacker to send money to the destination multiple times." Và lời khuyên đi kèm: "If you are part way through a transaction of any kind, it is extremely important that you roll back every part of the transaction and start again ... Attempting to recover a transaction part way through is often where we create unrecoverable mistakes."
2. Ba lớp của "giao dịch", mỗi lớp rollback khác nhau:
- Trong một database: transaction của DB làm việc này — mọi lệnh trong
DB::transaction()commit cùng nhau hoặc không lệnh nào. Bẫy: code tựbeginTransaction()rồi quênrollBack()ở một nhánh, connection giữ transaction mở, lock giữ mãi (trong process sống lâu như worker hay Octane, transaction lơ lửng đó còn "lây" sang job sau dùng chung connection — suy luận, chưa verify). - DB + tác dụng phụ ngoài DB: gửi mail, đẩy job, gọi API thanh toán, ghi file lên MinIO. DB rollback được, mail đã gửi thì không. Thứ tự đúng:
tác dụng phụ chỉ phát ra sau khi commit (Laravel:
afterCommit()khi dispatch job/mail — docs: nếu transaction rollback thì job đã dispatch trong đó "will be discarded"), hoặc dùng outbox (ghi "việc cần làm" vào cùng transaction, worker đọc ra làm sau). - Nhiều service: không có transaction chung; dùng saga — mỗi bước có bước bù trừ (compensation). Thiết kế này thuộc A06; A10 chỉ nhấn: bước bù trừ cũng có thể hỏng, nên phải idempotent và retry được.
3. Trạng thái "không biết" sau timeout. Gọi cổng thanh toán, timeout sau 10 giây — tiền đã trừ chưa? Retry mù có thể trừ hai lần; coi là thất bại có thể mất tiền của khách mà không ghi nhận. Cách đúng: gửi kèm idempotency key để retry an toàn, hoặc truy vấn trạng thái giao dịch trước khi quyết định (chi tiết idempotency ở bài Idempotency key).
sequenceDiagram
participant C as Client
participant A as App
participant DB as Database
participant Q as Queue
C->>A: POST chuyển điểm / thanh toán
A->>DB: BEGIN
A->>DB: trừ tài khoản A
A->>DB: cộng tài khoản B
A->>DB: ghi bản ghi giao dịch
Note over A,DB: lỗi ở bất kỳ bước nào: ROLLBACK cả ba
A->>DB: COMMIT
A->>Q: phát job gửi mail SAU commit
A-->>C: 200 + mã giao dịch
Cách nói khi phỏng vấn: "Kịch bản chuyển tiền của OWASP có ba bước: trừ bên gửi, cộng bên nhận, ghi giao dịch. Hỏng giữa chừng mà không rollback hết thì hoặc tiền mất, hoặc gửi được nhiều lần. Em chia ba lớp: trong một database thì để transaction làm, mọi bước cùng commit hoặc cùng huỷ; tác dụng phụ ra ngoài như mail, job thì chỉ phát sau commit; qua nhiều service thì dùng các bước bù trừ, và bước bù trừ phải chạy lại được. Riêng trường hợp timeout, em coi đó là 'không biết' chứ không phải 'thất bại', nên retry phải kèm idempotency key."
3.6 Không dọn tài nguyên (CWE-460) — lỗi nhỏ lặp lại thành DoS
1. Kịch bản #1 của OWASP: "Resource exhaustion via mishandling of exceptional conditions (Denial of Service) could be caused if the application catches exceptions when files are uploaded, but doesn't properly release resources after. Each new exception leaves resources locked or otherwise unavailable, until all resources are used up."
2. Tài nguyên hữu hạn nào hay bị rò khi có exception: file handle và file tạm (upload hỏng giữa chừng để lại file vài trăm MB trong
/tmp), connection DB lấy riêng (TypeORM QueryRunner tạo thủ công — docs TypeORM nhấn "you need to release query runner which is manually
created"), distributed lock (lấy lock rồi exception, không trả → mọi người phải chờ tới khi lock hết hạn), slot semaphore/concurrency limit, bộ nhớ trong process
sống lâu (worker, Node server) do mảng cache cục bộ chỉ thêm không xoá.
3. Ví dụ số (minh hoạ): pool 20 connection; mỗi request upload lỗi rò 1 connection; kẻ tấn công (hoặc chỉ một client lỗi) gửi 20 upload hỏng → pool cạn, mọi request khác chờ connection rồi timeout. 20 request là đủ để hạ dịch vụ — không cần botnet. Đó là lý do OWASP xếp kịch bản này vào DoS: chi phí tấn công thấp hơn chi phí phòng thủ rất nhiều lần.
4. Cách đúng: mọi tài nguyên lấy ra phải có đường trả không phụ thuộc vào việc có exception hay không — finally, hoặc API kiểu
"mượn trong closure" tự trả khi closure kết thúc (Laravel Cache::lock()->get(fn), TypeORM dataSource.transaction(fn)). Và mọi tài nguyên
có hạn sử dụng (lock có TTL, file tạm có job dọn, connection có idle timeout) để cái gì lọt lưới vẫn tự hết.
flowchart TD
G["Lấy tài nguyên:<br/>connection, lock, file tạm"] --> W["Làm việc"]
W -->|"thành công"| F["finally: trả tài nguyên"]
W -->|"exception"| F
F --> N["Pool / lock trở lại bình thường"]
W -.->|"thiếu finally"| LEAK["Rò: mỗi lỗi mất một slot<br/>20 lỗi = pool 20 cạn"]
Cách nói khi phỏng vấn: "Kịch bản đầu tiên của OWASP cho A10 là upload lỗi mà không trả tài nguyên. Mỗi lỗi mất một chỗ, và với pool hai mươi connection thì hai mươi request hỏng là đủ hạ dịch vụ, không cần botnet. Nên em có hai quy tắc: tài nguyên lấy ra luôn có đường trả không phụ thuộc vào việc có lỗi, tốt nhất là dùng kiểu mượn trong một hàm và tự trả; và mọi tài nguyên đều có hạn dùng, để thứ gì lọt lưới vẫn tự hết."
3.7 Phụ thuộc chậm hoặc chết — timeout, retry, circuit breaker
1. Chậm nguy hiểm hơn chết. Phụ thuộc chết hẳn (từ chối kết nối) thì lỗi trả về trong vài mili giây. Phụ thuộc treo thì mỗi request giữ một worker cho tới khi timeout — và timeout mặc định thường dài. Ví dụ worked với số của chính hệ thống trong repo: lab Rate limit phân tán ghi nhận Redis treo thì mỗi request kẹt tới 60 giây. Với một pool PHP-FPM 50 worker và 400 request/giây, nếu mỗi request kẹt 60 giây thì pool đầy trong 50 / 400 = 0,125 giây — sau đó toàn bộ site đứng, kể cả trang không cần Redis. Một phụ thuộc phụ treo kéo sập cả hệ thống: đó là cascading failure.
2. Timeout là giới hạn đầu tiên và quan trọng nhất. Mỗi lời gọi ra ngoài cần hai số: connect timeout (bắt tay TCP, nên ngắn — vài trăm mili giây
trong cùng datacenter) và tổng timeout (theo SLO của endpoint). Laravel HTTP client có connectTimeout (docs: mặc định 10 giây) và timeout.
Chuỗi timeout phải giảm dần từ ngoài vào trong: Nginx 60 giây > PHP max_execution_time > HTTP client gọi service khác — ngược lại thì tầng ngoài đã bỏ
cuộc mà tầng trong vẫn làm, tốn tài nguyên cho một câu trả lời không ai nhận.
3. Retry chữa lỗi thoáng qua nhưng nhân tải. Ba tầng mỗi tầng retry 3 lần → 3 × 3 × 3 = 27 lời gọi vào tầng cuối cho một request gốc, đúng lúc nó đang yếu (bài Vận hành rate limit mục 3.4 có tính chi tiết và ngân sách retry). Quy tắc: chỉ retry thao tác idempotent hoặc có idempotency key; backoff hàm mũ có jitter (ngẫu nhiên); giới hạn số lần; chỉ retry ở một tầng. Google Cloud 06/2025: khi các task Service Control khởi động lại, chúng tạo "a herd effect on the underlying infrastructure ... Service Control did not have the appropriate randomized exponential backoff implemented" — vùng us-central1 mất thêm khoảng 2 giờ 40 phút để hồi phục.
4. Circuit breaker (cầu dao): đếm lỗi của một phụ thuộc; quá ngưỡng thì "ngắt" — mọi lời gọi trả lỗi ngay không chờ (fail fast), sau một khoảng thời gian thì cho một vài lời gọi thử (half-open), thành công thì đóng lại. Nó biến "chậm" thành "lỗi nhanh", trả worker về pool. Nhưng nó không trả lời câu hỏi fail open hay closed — khi cầu dao ngắt, code vẫn phải chọn: từ chối, hay dùng giá trị thay thế (cache cũ, mặc định an toàn). Cầu dao cũng có giá: thêm trạng thái phải theo dõi, ngưỡng đặt sai thì ngắt oan hoặc không bao giờ ngắt, và mỗi process PHP-FPM không chia sẻ bộ nhớ nên trạng thái cầu dao phải nằm ở APCu/Redis — nếu nằm ở Redis mà chính Redis là thứ hỏng thì vòng lặp.
stateDiagram-v2
[*] --> Closed
Closed --> Open: lỗi vượt ngưỡng trong cửa sổ
Open --> HalfOpen: hết thời gian chờ
HalfOpen --> Closed: lời gọi thử thành công
HalfOpen --> Open: lời gọi thử thất bại
note right of Open: trả lỗi ngay, không chờ timeout - code vẫn phải chọn fail open hay closed
Cách nói khi phỏng vấn: "Phụ thuộc chậm nguy hiểm hơn phụ thuộc chết: chết thì lỗi trả về ngay, chậm thì mỗi request giữ một worker. Năm mươi worker, bốn trăm request mỗi giây, mỗi request kẹt sáu mươi giây thì pool đầy trong chưa tới hai phần mười giây và cả site đứng. Em xếp ba lớp: timeout ngắn dần từ ngoài vào trong; retry chỉ cho thao tác idempotent, có backoff ngẫu nhiên, và chỉ ở một tầng vì ba tầng retry ba lần là hai mươi bảy lời gọi; cuối cùng là circuit breaker để biến chậm thành lỗi nhanh. Nhưng cầu dao ngắt rồi thì vẫn phải chọn từ chối hay dùng giá trị thay thế — đó là quyết định fail open hay closed."
3.8 Lỗi bất đồng bộ — promise không ai chờ, job lỗi trong worker
1. Node.js: exception không ai bắt (CWE-248) làm chết cả process. Docs Node: mặc định khi một exception nổi lên tới event loop, Node "print[s]
the stack trace to stderr and exit[s] with code 1"; từ Node 15 chế độ --unhandled-rejections mặc định là throw, tức promise reject không ai
bắt cũng thành uncaught exception. Một process Node phục vụ hàng trăm request cùng lúc, nên một lỗi của một request giết tất cả. Đây là khác biệt lớn với PHP-FPM:
ở PHP, một request fatal error chỉ chết request đó.
2. Đừng "sửa" bằng cách nuốt ở process.on('uncaughtException'). Docs Node viết thẳng: "It is not safe to resume normal operation
after 'uncaughtException'" — "Unhandled exceptions inherently mean that an application is in an undefined state", và "The correct use of
'uncaughtException' is to perform synchronous cleanup of allocated resources ... before shutting down the process"; khởi động lại nên do "an external monitor
... in a separate process" (Docker restart policy, systemd, Kubernetes). Tức là: ghi log, dọn đồng bộ, thoát, để supervisor khởi động lại. Còn cách ngăn
thì ở code: luôn await hoặc gắn .catch, và bật luật lint bắt promise trôi nổi (mục 6.2).
3. Queue worker: job lỗi có ba số phận — retry, chuyển vào failed_jobs, hoặc treo. Bẫy A10 trong worker Laravel (theo docs Laravel 13):
- Không đặt
tries/maxExceptions→ job lỗi vĩnh viễn ("poison message") retry mãi, chiếm worker. Docs:#[Tries(25)]+#[MaxExceptions(3)]cho phép release nhiều lần nhưng fail sau 3 exception. --timeoutcủa worker dài hơnretry_aftercủa connection → job còn đang chạy đã bị coi là mất và giao cho worker khác: docs cảnh báo "If your --timeout option is longer than your retry_after configuration value, your jobs may be processed twice."- Job timeout mặc định tiêu một lần thử và quay lại hàng đợi; job không được phép chạy lại (gửi tiền, gửi SMS trả phí) nên gắn
#[FailOnTimeout]— docs: khi đó job "will not be retried, regardless of the value set for tries". - Không có method
failed()→ job thất bại hẳn mà trạng thái nghiệp vụ vẫn là "đang xử lý" mãi (ví dụ báo cáo "đang tạo" không bao giờ xong, người dùng bấm lại tạo job mới).failed()là chỗ đưa trạng thái về "thất bại" và trả tài nguyên.
flowchart TD
J["Job chạy trong worker"] --> R{"Kết quả"}
R -->|"thành công"| D["Xoá khỏi queue"]
R -->|"exception"| T{"Còn lượt thử<br/>và chưa quá maxExceptions?"}
T -->|"còn"| B["Release lại với backoff"]
T -->|"hết"| F["failed(): trạng thái nghiệp vụ = thất bại<br/>trả lock, xoá file tạm, báo người dùng"]
F --> FJ["failed_jobs + cảnh báo khi tăng"]
R -->|"timeout"| TO{"FailOnTimeout?"}
TO -->|"có"| F
TO -->|"không"| T
Cách nói khi phỏng vấn: "Lỗi bất đồng bộ có hai chỗ hay hỏng. Ở Node, một promise bị reject mà không ai chờ thì từ bản mười lăm sẽ làm chết cả process, kéo theo mọi request đang phục vụ; docs Node nói rõ không an toàn để chạy tiếp sau lỗi không bắt, nên em ghi log rồi thoát cho supervisor khởi động lại, còn cách ngăn là luôn chờ promise và bật luật lint. Ở queue worker, em đặt số lần thử và số exception tối đa, timeout của worker ngắn hơn thời gian queue coi job là mất, job không được chạy lại thì đánh dấu thất bại khi timeout, và luôn có hàm xử lý khi job thất bại hẳn để trạng thái nghiệp vụ không treo ở 'đang xử lý'."
3.9 Hình động: một exception đi qua năm lớp phòng thủ
Hình dưới là luồng phòng thủ đầy đủ cho một tình huống điển hình: kiểm tra "tài khoản có bị khoá" gọi Redis và Redis không trả lời trong 200 ms. Thẻ xanh đi qua
năm lớp theo đúng thứ tự mục 4 cài đặt; thanh dưới cùng là handler toàn cục — lưới an toàn cho mọi thứ lọt qua. Thẻ đỏ là thiết kế sai phổ biến nhất: catch
trả "không bị khoá".
prefers-reduced-motion:
khi tắt hoạt ảnh, hiện trạng thái cuối.Cách nói khi phỏng vấn: "Em hình dung một tình huống bất thường đi qua năm lớp. Lớp một chặn trước: input đã kiểm, lời gọi Redis có timeout ngắn chứ không chờ cả phút. Lớp hai bắt đúng loại lỗi mình xử lý được, ở đây là lỗi kết nối Redis; lỗi lập trình thì để nổi lên. Lớp ba dọn trạng thái: rollback nếu đã ghi gì, trả connection và lock. Lớp bốn quyết định an toàn: hỏi nguồn gốc là database, database cũng lỗi thì từ chối. Lớp năm trả thông điệp chung kèm mã tra cứu, chi tiết vào log, tăng metric để có cảnh báo. Bên dưới tất cả là handler toàn cục làm lưới an toàn. Sai phổ biến nhất là bắt được lỗi rồi trả về 'không bị khoá' cho tiện."
4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)
Code dưới đây là mẫu rút gọn để đọc trong phỏng vấn, không phải module hoàn chỉnh. API Laravel 13, NestJS, TypeORM, Node.js đã tra qua Context7 (docs chính thức); một
số chi tiết đối chiếu thêm với source laravel/framework nhánh 13.x (Foundation/Exceptions/Handler.php,
Foundation/Configuration/Exceptions.php, Cache/Lock.php). Tên lớp, tên bảng, con số là ví dụ minh hoạ cho LMS.
Toàn bộ mục này chưa chạy trong lab (series không lab).
4.1 Handler lỗi tập trung — một chỗ, một cách, thông điệp chung kèm mã tra cứu
Mẫu sai — mỗi controller tự bắt và tự trả lỗi theo kiểu riêng, kèm chi tiết nội bộ (CWE-209):
// Laravel — controller
try {
$report = $this->reports->build($courseId);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()], 500);
}
// NestJS — service
try { return await this.repo.find(...); }
catch (err) { throw new InternalServerErrorException(err.message); } // câu SQL lọt ra client
Cách nói khi phỏng vấn: "Hai đoạn này cùng một lỗi: lấy thông điệp gốc của exception, vốn chứa câu SQL hay đường dẫn file, rồi đưa thẳng cho người dùng. Và vì mỗi chỗ tự viết, mỗi endpoint trả lỗi một kiểu, không ai review hết được. OWASP yêu cầu ngược lại: xử lý lỗi ở một chỗ, theo một cách, mọi lần."
Mẫu đúng — Laravel 13 (bootstrap/app.php). Điểm dựa vào source 13.x: (a) render callback trả null thì Handler
chuyển sang callback tiếp theo rồi tới render mặc định (renderViaCallbacks chỉ dùng response khác null); (b) render mặc định dạng JSON khi
app.debug = false chỉ trả 'message' => 'Server Error' cho exception không phải HTTP, còn khi true thì trả cả
exception, file, line, trace (convertExceptionToArray) — nên APP_DEBUG=false trên production là
điều kiện bắt buộc (phần cấu hình ở Bài 2). Request id lấy từ Laravel Context do middleware gắn vào (cách gắn ở
Bài 9 mục 4.2).
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Illuminate\Validation\ValidationException;
->withExceptions(function (Exceptions $exceptions): void {
// 1. Gắn ngữ cảnh vào MỌI báo cáo lỗi (log, Sentry) — một chỗ
$exceptions->context(fn () => [
'request_id' => Context::get('request_id'),
'tenant' => Context::get('tenant'),
]);
// 2. Lỗi giống hệt lặp lại: không làm ngập log (gợi ý của OWASP A10)
$exceptions->dontReportDuplicates();
$exceptions->throttle(fn (Throwable $e) => Limit::perMinute(300));
// 3. API: lỗi phía server = thông điệp chung + mã tra cứu; lỗi 4xx giữ hành vi mặc định
$exceptions->render(function (Throwable $e, Request $request) {
if (! $request->is('api/*')
|| $e instanceof HttpExceptionInterface // 403, 404, 429... đã có thông điệp an toàn
|| $e instanceof ValidationException) { // 422 + danh sách trường sai
return null; // null = để Laravel render mặc định
}
return response()->json([
'message' => 'Hệ thống đang gặp sự cố, vui lòng thử lại sau.',
'request_id' => Context::get('request_id'),
], 500);
});
})
Cách nói khi phỏng vấn: "Ở Laravel em gom toàn bộ xử lý lỗi vào phần cấu hình exception trong file khởi động ứng dụng. Em gắn mã request và tenant vào mọi báo cáo lỗi ở một chỗ; lỗi giống nhau lặp lại thì không báo trùng và có giới hạn số lần báo mỗi phút để log không bị ngập. Với API, lỗi phía server trả một câu chung kèm mã request để người dùng báo lại; lỗi phía client như không có quyền, không tìm thấy, dữ liệu sai thì giữ hành vi mặc định vì thông điệp của chúng đã an toàn. Và điều kiện nền là tắt chế độ debug trên production, vì khi bật, response lỗi chứa cả file, dòng và stack trace."
Mẫu đúng — NestJS. Theo docs NestJS: filter global mặc định chỉ hiểu HttpException; exception khác nhận body
{"statusCode": 500, "message": "Internal server error"} — đã an toàn về nội dung, nhưng không log theo cách của mình và không có mã tra cứu. Filter
@Catch() rỗng bắt mọi thứ; dùng HttpAdapterHost để không phụ thuộc Express hay Fastify; đăng ký qua provider APP_FILTER thay vì
app.useGlobalFilters() vì docs ghi cách sau "cannot inject dependencies" và "does not set up filters for gateways or hybrid applications".
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { randomUUID } from 'node:crypto';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('Exceptions');
constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
if (host.getType() !== 'http') throw exception; // RPC/WS: để filter riêng xử lý
const { httpAdapter } = this.adapterHost;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const requestId: string = req.headers['x-request-id'] ?? randomUUID();
const isHttp = exception instanceof HttpException;
const status = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
if (status >= 500) {
const stack = exception instanceof Error ? exception.stack : String(exception);
this.logger.error(`unhandled requestId=${requestId} path=${httpAdapter.getRequestUrl(req)}`, stack);
}
const body = isHttp && status < 500
? exception.getResponse() // 400/403/404: thông điệp do mình đặt
: { statusCode: status, message: 'Hệ thống đang gặp sự cố, vui lòng thử lại sau.', requestId };
httpAdapter.reply(ctx.getResponse(), body, status);
}
}
// app.module.ts
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }]
Cách nói khi phỏng vấn: "Ở NestJS em viết một exception filter bắt mọi loại lỗi và đăng ký nó như provider toàn cục để nó nhận được dependency. Với lỗi phía server, filter ghi log đầy đủ stack kèm mã request, còn client chỉ nhận mã trạng thái, một câu chung và mã request. Với lỗi phía client như sai dữ liệu hay không có quyền, filter giữ thông điệp mà code đã chủ động đặt. Bộ lọc mặc định của Nest vốn đã không lộ chi tiết, nhưng nó không log theo chuẩn của mình và không có mã tra cứu — nên em vẫn thay."
flowchart LR
T["Exception bất kỳ"] --> Q{"Lỗi phía client?<br/>(HttpException < 500,<br/>ValidationException)"}
Q -->|"có"| C4["Giữ thông điệp an toàn<br/>đã đặt sẵn: 4xx"]
Q -->|"không"| L["Log: stack + request_id + tenant"]
L --> C5["Client: 500 + câu chung + request_id"]
4.2 Fail closed khi phụ thuộc kiểm tra bị lỗi — hỏi nguồn gốc, rồi mới từ chối
Mẫu sai — catch trả giá trị "cho qua":
// Laravel middleware
try {
$locked = Redis::sismember('locked_users', $request->user()->id);
} catch (\Throwable $e) {
$locked = false; // "đừng làm phiền người dùng" — CWE-636
}
// NestJS guard
try { return (await this.redis.sismember('locked_users', String(userId))) === 0; }
catch { return true; } // Redis chết = ai cũng qua
Cách nói khi phỏng vấn: "Cả hai đoạn đều bắt lỗi Redis rồi trả về 'không bị khoá'. Đúng lúc Redis gặp sự cố, mọi tài khoản đã bị khoá vì nghi bị chiếm lại đăng nhập được, và không có dòng log nào. Đây đúng là định nghĩa fail open của CWE sáu trăm ba mươi sáu."
Mẫu đúng — Laravel. Redis ở đây chỉ là bản sao nhanh; nguồn gốc là cột locked_at trong DB. Khi bản sao lỗi thì hỏi nguồn gốc; nguồn gốc
cũng lỗi thì exception của DB nổi lên handler (500) — vẫn là đóng. Chỉ bắt đúng lỗi của Redis (\RedisException với phpredis; predis ném lớp khác).
final class EnsureAccountNotLocked
{
public function handle(Request $request, Closure $next): Response
{
$userId = $request->user()?->id ?? abort(401);
try {
$locked = (bool) Redis::connection('security')->sismember('locked_users', (string) $userId);
} catch (\RedisException $e) {
Log::channel('security')->warning('authz_dependency_unavailable', [
'check' => 'account_locked', 'error' => $e::class, // không ghi message: có thể chứa host/port
]);
// Nguồn gốc: DB. DB lỗi thì QueryException nổi lên handler → 500 (vẫn là đóng)
$locked = User::whereKey($userId)->whereNotNull('locked_at')->exists();
}
if ($locked) {
abort(403, 'Tài khoản đang bị tạm khoá.');
}
return $next($request);
}
}
Cách nói khi phỏng vấn: "Em không chọn giữa 'cho qua' và 'chặn hết' ngay. Redis ở đây chỉ là bản sao nhanh của danh sách khoá, nguồn gốc là database. Redis lỗi thì em ghi một sự kiện bảo mật có tên, rồi hỏi database; database cũng lỗi thì exception nổi lên handler chung và người dùng nhận lỗi năm trăm — tức là vẫn đóng. Em chỉ bắt đúng loại lỗi của Redis; lỗi lập trình thì không nuốt. Và em không ghi thông điệp lỗi gốc vào log vì nó có thể chứa địa chỉ nội bộ."
Mẫu đúng — NestJS. Guard trả false thì Nest ném ForbiddenException (403). Timeout của lời gọi Redis đặt ngắn (helper
withTimeout ở 4.5) để phụ thuộc chậm không giữ request.
@Injectable()
export class AccountNotLockedGuard implements CanActivate {
private readonly logger = new Logger(AccountNotLockedGuard.name);
constructor(@Inject(SECURITY_REDIS) private readonly redis: Redis, private readonly users: UsersRepository) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const userId: number | undefined = ctx.switchToHttp().getRequest().user?.id;
if (userId === undefined) return false; // không xác định được người = không cho
try {
const isMember = await withTimeout(this.redis.sismember('locked_users', String(userId)), 200);
return isMember === 0;
} catch (err) {
this.logger.warn(`authz_dependency_unavailable check=account_locked err=${(err as Error).name}`);
const lockedAt = await this.users.findLockedAt(userId); // DB lỗi thì ném → filter → 500
return lockedAt === null;
}
}
}
Cách nói khi phỏng vấn: "Ở NestJS em làm giống hệt bằng một guard: không xác định được người dùng thì trả về không cho; gọi Redis có timeout hai trăm mili giây; Redis lỗi hay chậm thì ghi cảnh báo rồi hỏi database; database cũng lỗi thì exception đi tới filter chung. Không có nhánh nào mà lỗi dẫn tới 'cho qua'."
flowchart TD
G["Guard / middleware"] --> R{"Redis (bản sao) trả lời<br/>trong 200 ms?"}
R -->|"có"| D1{"Bị khoá?"}
R -->|"lỗi / timeout"| W["Log authz_dependency_unavailable"]
W --> DB{"DB (nguồn gốc) trả lời?"}
DB -->|"có"| D1
DB -->|"lỗi"| E["Exception → handler → 500<br/>(vẫn là đóng)"]
D1 -->|"có"| F["403"]
D1 -->|"không"| P["Cho qua"]
4.3 Giao dịch nhiều bước — rollback trọn vẹn, tác dụng phụ sau commit
Mẫu sai:
// Laravel
DB::beginTransaction();
$from->decrement('points', $amount);
Mail::to($to->owner)->send(new PointsReceived($amount)); // gửi khi chưa chắc đã xong
$to->increment('points', $amount); // exception ở đây: không ai rollback
DB::commit();
// NestJS + TypeORM
await this.dataSource.transaction(async (manager) => {
await manager.decrement(Wallet, { id: fromId }, 'points', amount);
await this.walletRepo.increment({ id: toId }, 'points', amount); // repo inject sẵn: NGOÀI transaction
});
Cách nói khi phỏng vấn: "Đoạn Laravel có hai lỗi: tự mở transaction mà không có nhánh rollback, nên lỗi giữa chừng để transaction treo trên connection; và gửi mail trước khi chắc chắn giao dịch thành công. Đoạn NestJS tinh vi hơn: bước thứ hai dùng repository được inject sẵn chứ không dùng manager của transaction, nên nó chạy ngoài transaction — docs TypeORM nhấn rằng mọi thao tác phải đi qua manager được truyền vào. Bước một rollback được, bước hai thì không."
Mẫu đúng — Laravel. DB::transaction(closure) tự rollback khi closure ném exception rồi ném lại; tham số attempts chỉ để
thử lại khi deadlock (docs: "Once attempts are exhausted, an exception is thrown"). Mail/job phát qua afterCommit() — docs: job phát trong
transaction bị rollback thì bị huỷ.
DB::transaction(function () use ($fromId, $toId, $amount, $idempotencyKey) {
// Khoá theo thứ tự id tăng dần để hai giao dịch ngược chiều không deadlock nhau
$wallets = Wallet::whereKey([$fromId, $toId])->orderBy('id')->lockForUpdate()->get()->keyBy('id');
$from = $wallets[$fromId] ?? throw new ModelNotFoundException();
$to = $wallets[$toId] ?? throw new ModelNotFoundException();
if ($from->points < $amount) {
throw new InsufficientPoints(); // ném = rollback; handler map sang 422
}
$from->decrement('points', $amount);
$to->increment('points', $amount);
Transfer::create([ // unique index trên idempotency_key:
'from_id' => $fromId, 'to_id' => $toId, // retry trùng thì vi phạm unique → rollback cả ba
'amount' => $amount, 'idempotency_key' => $idempotencyKey,
]);
Mail::to($to->owner)->send((new PointsReceived($amount))->afterCommit()); // mailable ShouldQueue
}, attempts: 3);
Cách nói khi phỏng vấn: "Em để framework quản transaction bằng dạng closure: closure ném lỗi thì rollback rồi ném lại, nên không có nhánh nào quên. Trong đó em khoá hai ví theo thứ tự id để tránh deadlock, kiểm số dư, ném lỗi nghiệp vụ nếu không đủ, ghi cả ba bước, và bản ghi giao dịch có unique key để một lần retry trùng sẽ làm hỏng cả giao dịch thay vì chuyển hai lần. Mail chỉ được xếp hàng sau khi commit; nếu rollback thì mail bị huỷ theo."
Mẫu đúng — NestJS + TypeORM. Mọi thao tác đi qua manager; tác dụng phụ ghi vào bảng outbox trong cùng transaction, một worker
đọc outbox và gửi sau — mail không bao giờ đi trước commit, cũng không bị mất nếu process chết ngay sau commit.
await this.dataSource.transaction(async (manager) => {
const [a, b] = [fromId, toId].sort((x, y) => x - y); // thứ tự khoá cố định
const rows = await manager.find(Wallet, { where: { id: In([a, b]) }, order: { id: 'ASC' },
lock: { mode: 'pessimistic_write' } });
const from = rows.find((w) => w.id === fromId);
const to = rows.find((w) => w.id === toId);
if (!from || !to) throw new NotFoundException();
if (from.points < amount) throw new UnprocessableEntityException('Không đủ điểm');
from.points -= amount;
to.points += amount;
await manager.save([from, to]);
await manager.insert(Transfer, { fromId, toId, amount, idempotencyKey });
await manager.insert(Outbox, { type: 'points_received', payload: { toId, amount } });
}); // callback ném → TypeORM rollback rồi ném lại cho caller
Cách nói khi phỏng vấn: "Bên NestJS em dùng hàm transaction của TypeORM và mọi câu lệnh đều đi qua manager nó truyền vào. Thay vì gửi mail trực tiếp, em ghi một dòng vào bảng outbox trong cùng transaction; worker đọc outbox rồi mới gửi. Như vậy mail không bao giờ đi khi giao dịch chưa commit, và không bị mất nếu process chết ngay sau commit — đổi lại là mail có thể gửi trễ vài giây và worker phải xử lý trùng."
flowchart TD
S["BEGIN"] --> L["Khoá 2 ví theo thứ tự id"]
L --> C{"Đủ điểm?"}
C -->|"không"| RB["Ném lỗi nghiệp vụ → ROLLBACK"]
C -->|"có"| W["Trừ, cộng, ghi Transfer (unique key),<br/>ghi Outbox"]
W -->|"bất kỳ exception"| RB
W --> CM["COMMIT"]
CM --> SE["Sau commit: worker đọc Outbox / afterCommit<br/>mới gửi mail"]
4.4 Dọn tài nguyên — đường trả không phụ thuộc vào việc có lỗi
Mẫu sai:
// Laravel — job chấm điểm
$lock = Cache::lock("grade:{$submissionId}", 120);
$lock->get(); // không kiểm kết quả (CWE-252): không lấy được lock vẫn chấm
$this->grade($submissionId); // exception → dòng dưới không chạy
$lock->release(); // lock giữ tới hết 120 giây (CWE-460)
// NestJS — chép nguyên ví dụ trong docs TypeORM
await queryRunner.startTransaction();
try { /* ... */ await queryRunner.commitTransaction(); }
catch (err) { await queryRunner.rollbackTransaction(); } // KHÔNG ném lại: caller tưởng thành công
finally { await queryRunner.release(); }
Cách nói khi phỏng vấn: "Đoạn Laravel có hai lỗi chồng nhau: không kiểm việc lấy lock có thành công không, nên hai worker có thể cùng chấm; và nếu chấm bị lỗi thì lock không được trả, các lần sau phải chờ hết hạn. Đoạn NestJS là bẫy rất hay gặp: ví dụ trong docs TypeORM có rollback và release đầy đủ nhưng nhánh bắt lỗi không ném lại — chép nguyên vào code thật thì lỗi bị nuốt và hàm gọi tưởng đã thành công."
Mẫu đúng — Laravel. Source Cache/Lock.php nhánh 13.x: get($callback) chỉ chạy callback khi lấy được lock và gọi
release() trong finally; không lấy được lock thì trả false mà không chạy callback.
// Job chấm điểm
public function handle(): void
{
$acquired = Cache::lock("grade:{$this->submissionId}", 120)->get(function () {
$this->grader->grade($this->submissionId); // exception ở đây: lock vẫn được trả (finally)
return true;
});
if ($acquired === false) {
$this->release(30); // worker khác đang chấm bài này: thử lại sau
}
}
// Upload: file tạm luôn bị xoá, kể cả khi quét virus hay lưu MinIO lỗi
$tmp = tempnam(sys_get_temp_dir(), 'upl');
try {
$this->scanner->scan($request->file('video'), $tmp);
Storage::disk('minio')->putFileAs('videos', new File($tmp), $name);
} finally {
if (is_file($tmp)) {
unlink($tmp);
}
}
Cách nói khi phỏng vấn: "Ở Laravel em dùng dạng lấy lock kèm closure: framework chỉ chạy closure khi lấy được lock và luôn trả lock trong khối finally, kể cả khi closure ném lỗi. Không lấy được lock thì em không chấm mà đưa job về hàng đợi chờ ba mươi giây. Với file tạm khi upload, em đặt việc xoá trong finally — quét virus hay lưu MinIO hỏng thì file tạm vẫn bị dọn, nên một loạt upload lỗi không làm đầy đĩa."
Mẫu đúng — NestJS. Ưu tiên dataSource.transaction(fn) (tự quản connection). Khi bắt buộc dùng QueryRunner thủ công thì
rollback rồi ném lại, và release() trong finally.
const qr = this.dataSource.createQueryRunner();
await qr.connect();
await qr.startTransaction();
try {
await qr.manager.save(enrollment);
await qr.manager.save(invoice);
await qr.commitTransaction();
} catch (err) {
await qr.rollbackTransaction();
throw err; // để filter / caller biết là thất bại
} finally {
await qr.release(); // connection về pool trong MỌI trường hợp
}
Cách nói khi phỏng vấn: "Với TypeORM em ưu tiên hàm transaction dạng callback để thư viện tự quản connection. Khi phải mở query runner thủ công, em giữ ba quy tắc: lỗi thì rollback rồi ném lại, không bao giờ chỉ rollback rồi im lặng; việc trả connection đặt trong finally; và test có một ca cố tình ném lỗi giữa chừng rồi kiểm số connection trong pool không giảm."
4.5 Timeout, retry có điều kiện, và cầu dao
Mẫu sai: gọi API ngoài không timeout (Laravel HTTP client mặc định connect timeout 10 giây theo docs; tổng timeout mặc định — chưa verify trong bài), retry mọi lỗi kể cả lỗi 4xx và thao tác không idempotent, và đọc body mà không kiểm mã trạng thái:
$res = Http::retry(5, 0)->post($paymentUrl, $payload); // không backoff, POST thanh toán retry 5 lần
$txId = $res->json('transaction_id'); // 500 trả trang lỗi → null, chạy tiếp
Cách nói khi phỏng vấn: "Dòng này có ba lỗi: không có backoff nên năm lần thử dồn vào cùng một lúc đối tác đang yếu; retry một lệnh thanh toán không có khoá chống trùng nên có thể trừ tiền nhiều lần; và đọc mã giao dịch mà không kiểm mã trạng thái, nên khi đối tác trả trang lỗi thì code nhận giá trị rỗng và chạy tiếp như đã có giao dịch."
Mẫu đúng — Laravel. Theo docs: retry() nhận mảng số mili giây chờ giữa các lần, callback when quyết định có thử lại không,
throw mặc định true; khi mọi lần thử đều lỗi kết nối thì ConnectionException luôn được ném.
use Illuminate\Http\Client\ConnectionException;
$res = Http::connectTimeout(1) // bắt tay TCP: ngắn
->timeout(5) // tổng: theo SLO của endpoint gọi
->withHeaders(['Idempotency-Key' => $key]) // đối tác chống trùng → retry an toàn
->retry([200, 800], when: fn (Throwable $e) => $e instanceof ConnectionException)
->post($paymentUrl, $payload);
$res->throw(); // 4xx/5xx thành exception, không đọc nhầm body lỗi
$txId = $res->json('transaction_id') ?? throw new UnexpectedPaymentResponse();
Cách nói khi phỏng vấn: "Em đặt hai timeout: bắt tay một giây, tổng năm giây theo mức cam kết của endpoint. Em chỉ thử lại khi lỗi là lỗi kết nối, tối đa hai lần với khoảng chờ tăng dần, và gửi kèm khoá chống trùng để đối tác không xử lý một lệnh hai lần. Sau đó em buộc mã trạng thái lỗi thành exception, và nếu body thiếu mã giao dịch thì cũng ném lỗi chứ không để giá trị rỗng chạy tiếp."
Cầu dao cho job gọi đối tác không ổn định — Laravel. Docs Laravel 13: job middleware ThrottlesExceptions($maxExceptions, $decaySeconds)
— sau số exception cho phép thì job bị hoãn $decaySeconds giây; when() giới hạn loại exception được tính; deleteWhen() xoá hẳn
job với một loại exception. Đây thực chất là circuit breaker cho hàng đợi: đối tác sập thì worker không đốt hết lượt thử của 40.000 job vào một cái API đang chết.
public function middleware(): array
{
return [(new ThrottlesExceptions(10, 5 * 60)) // 10 lỗi → nghỉ 5 phút
->by('partner-sms') // cùng một "cầu dao" cho mọi job gọi đối tác này
->when(fn (Throwable $e) => $e instanceof ConnectionException)
->report()]; // vẫn báo lỗi về handler
}
Cách nói khi phỏng vấn: "Với job gọi đối tác, Laravel có sẵn một middleware hoạt động như cầu dao: sau mười lỗi kết nối thì mọi job dùng chung khoá đó nghỉ năm phút. Nhờ vậy khi đối tác gửi tin nhắn sập, hàng chục nghìn job không lần lượt đốt hết số lần thử vào một API đang chết, rồi rơi hết vào bảng job thất bại."
Mẫu đúng — NestJS. fetch của Node không reject với 4xx/5xx và không có timeout mặc định ngắn; AbortSignal.timeout(ms) giải
quyết phần timeout (Web API có trong Node — theo hiểu biết, chưa tra lại trong bài). Cầu dao viết tay dưới đây là để giải thích cơ chế; production thường dùng thư viện
(ví dụ opossum — chưa tra docs) hoặc đặt ở service mesh.
export function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return Promise.race([p, new Promise<never>((_, rej) => setTimeout(() => rej(new Error('timeout')), ms).unref())]);
}
export class CircuitBreaker {
private failures = 0;
private openedAt = 0;
constructor(private readonly threshold = 5, private readonly coolDownMs = 30_000) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.failures >= this.threshold && Date.now() - this.openedAt < this.coolDownMs) {
throw new ServiceUnavailableException(); // Open: lỗi nhanh, không chiếm kết nối
}
try {
const result = await fn(); // Closed, hoặc Half-open sau thời gian chờ
this.failures = 0;
return result;
} catch (err) {
this.failures += 1;
if (this.failures >= this.threshold) this.openedAt = Date.now();
throw err;
}
}
}
// dùng
const res = await this.smsBreaker.call(() =>
fetch(url, { method: 'POST', body, headers, signal: AbortSignal.timeout(3000) }));
if (!res.ok) throw new BadGatewayException(); // fetch không tự ném với 4xx/5xx
Cách nói khi phỏng vấn: "Cầu dao đếm lỗi liên tiếp của một đối tác. Vượt ngưỡng năm lần thì trong ba mươi giây mọi lời gọi bị từ chối ngay, không chờ timeout, nên kết nối và worker được giải phóng. Hết ba mươi giây thì cho một lời gọi đi thử: thành công thì đóng cầu dao, thất bại thì mở lại. Ngoài ra em luôn kiểm mã trạng thái vì hàm fetch không tự báo lỗi với mã bốn trăm hay năm trăm. Đoạn này em viết tay để giải thích; production em dùng thư viện đã được kiểm chứng."
flowchart TD
A["Lời gọi ra ngoài"] --> B{"Cầu dao mở?"}
B -->|"mở, chưa hết chờ"| F["Lỗi ngay: 503<br/>giải phóng worker"]
B -->|"đóng / thử lại"| T["Gọi với timeout 3 giây"]
T -->|"ok + status 2xx"| OK["Kết quả; đếm lỗi = 0"]
T -->|"lỗi / timeout / 5xx"| C["Đếm lỗi + 1<br/>đủ ngưỡng thì mở cầu dao"]
C --> R{"Lỗi kết nối và<br/>thao tác idempotent?"}
R -->|"có"| RT["Retry có backoff + jitter,<br/>tối đa 2 lần, một tầng"]
R -->|"không"| E["Ném lỗi lên trên"]
4.6 Input bất thường — chặn ở cửa, kiểu hoá, và không rơi về mặc định
Mẫu sai:
// Laravel
$perPage = $request->input('per_page', 20); // không trần: 1.000.000 cũng nhận
$enrollment->update($request->all()); // thừa trường: role, tenant_id... (CWE-235)
$rate = $done / $total; // khoá học 0 bài → DivisionByZeroError
// NestJS
switch (member.role) {
case 'teacher': case 'admin': canGrade = true; break;
case 'student': canGrade = false; break;
} // vai trò mới 'assistant': giữ giá trị khởi tạo
Cách nói khi phỏng vấn: "Bốn dòng này là bốn kiểu input bất thường: số không có trần, request có trường thừa được gán thẳng vào model, chia cho tổng có thể bằng không, và một switch không có nhánh mặc định — thêm vai trò mới thì quyền giữ nguyên giá trị khởi tạo, không ai biết."
Mẫu đúng — Laravel. $request->validated() chỉ trả các trường đã khai báo trong rules. Enum + match: thiếu nhánh thì PHP ném
UnhandledMatchError thay vì lặng lẽ rơi về giá trị khởi tạo.
// FormRequest
public function rules(): array
{
return [
'course_id' => ['required', 'integer', 'exists:courses,id'],
'per_page' => ['sometimes', 'integer', 'between:1,100'],
'video' => ['required', 'file', 'mimetypes:video/mp4', 'max:512000'], // KB = 500 MB
];
}
// Controller / service
$data = $request->validated(); // chỉ 3 trường trên
$perPage = $data['per_page'] ?? 20;
$rate = $total === 0 ? 0.0 : $done / $total; // trường hợp biên được viết ra, không để nổ
$canGrade = match ($member->role) { // Role là backed enum
Role::Teacher, Role::Admin => true,
Role::Student, Role::Assistant => false,
}; // thêm Role::Observer mà quên sửa → UnhandledMatchError, không lặng lẽ cho/không cho
Cách nói khi phỏng vấn: "Ở Laravel em validate ở cửa bằng form request: bắt buộc, đúng kiểu, có khoảng giá trị, file có loại và kích thước tối đa, rồi chỉ lấy những trường đã khai báo. Trường hợp biên như tổng bằng không thì viết ra thành một nhánh rõ ràng. Còn với vai trò, em dùng enum và biểu thức match: ai thêm vai trò mới mà quên cập nhật thì chương trình báo lỗi ngay, chứ không lặng lẽ cho hoặc không cho quyền. Giới hạn kích thước còn phải đặt ở Nginx và PHP nữa, vì validate của Laravel chạy sau khi file đã được nhận xong."
Mẫu đúng — NestJS. Docs NestJS: whitelist: true bỏ các thuộc tính không có decorator; thêm forbidNonWhitelisted: true thì từ
chối request có thuộc tính lạ. Giới hạn body: docs ghi mặc định Express 100kb, Fastify 1 MiB, đổi bằng app.useBodyParser().
// main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useBodyParser('json', { limit: '100kb' }); // nói rõ giới hạn thay vì dựa vào mặc định
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
// DTO
export class ListSubmissionsQuery {
@Type(() => Number) @IsInt() @Min(1) @Max(100) @IsOptional()
perPage: number = 20;
}
// Logic: TypeScript bắt thiếu nhánh lúc biên dịch
function canGrade(role: Role): boolean {
switch (role) {
case 'teacher': case 'admin': return true;
case 'student': case 'assistant': return false;
default: {
const unreachable: never = role; // thêm 'observer' vào Role → lỗi biên dịch ở đây
throw new Error(`Vai trò không xử lý: ${String(unreachable)}`);
}
}
}
Cách nói khi phỏng vấn: "Ở NestJS em bật pipe validate toàn cục, bỏ trường không khai báo và từ chối hẳn request có trường lạ, chuyển kiểu chuỗi sang số, và đặt rõ giới hạn kích thước body. Trong logic, em dùng kiểm tra đủ nhánh của TypeScript: nhánh mặc định gán giá trị vào kiểu không thể xảy ra, nên ai thêm vai trò mới mà quên xử lý thì không biên dịch được — lỗi bị chặn từ lúc build chứ không phải lúc production."
4.7 Bất đồng bộ và worker — không nuốt, không chạy tiếp trong trạng thái không xác định
Mẫu sai:
// NestJS
this.mailer.sendWelcome(user); // không await: reject thì không ai bắt
process.on('uncaughtException', (err) => logger.error(err)); // nuốt lỗi, process chạy tiếp
// Laravel job: không giới hạn, không failed()
class GenerateReport implements ShouldQueue
{
public function handle(): void { /* ... */ } // lỗi mãi → retry mãi; hỏng hẳn → báo cáo "đang tạo" vĩnh viễn
}
Cách nói khi phỏng vấn: "Ba lỗi kinh điển: gọi hàm bất đồng bộ mà không chờ, nên lỗi của nó không ai bắt và từ Node mười lăm sẽ làm chết process; gắn handler cho lỗi không bắt chỉ để log rồi chạy tiếp, trong khi docs Node nói trạng thái lúc đó là không xác định; và job không có giới hạn lần thử hay hàm xử lý khi thất bại, nên báo cáo treo ở trạng thái đang tạo mãi mãi."
Mẫu đúng — NestJS / Node. Docs Node: listener 'uncaughtExceptionMonitor' cho phép quan sát mà không thay hành vi mặc định (in stack,
thoát mã 1); khởi động lại là việc của process giám sát bên ngoài. enableShutdownHooks() để các hook onApplicationShutdown chạy khi nhận tín hiệu
dừng (docs NestJS lifecycle).
// main.ts
import { writeSync } from 'node:fs';
process.on('uncaughtExceptionMonitor', (err, origin) => {
// ghi đồng bộ một dòng JSON trước khi Node thoát với mã 1 — KHÔNG chặn việc thoát
writeSync(process.stderr.fd, JSON.stringify({ level: 'fatal', origin, name: err.name, stack: err.stack }) + '\n');
});
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // SIGTERM khi deploy: đóng pool DB, Redis gọn gàng
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();
// Gọi bất đồng bộ: hoặc await, hoặc "bắn rồi quên" CÓ CHỦ ĐÍCH với catch tường minh
await this.mailer.sendWelcome(user);
// hoặc
void this.mailer.sendWelcome(user).catch((err) => this.logger.warn(`welcome_mail_failed user=${user.id} err=${err.name}`));
Cách nói khi phỏng vấn: "Ở Node em không chặn việc process chết khi có lỗi không bắt, vì docs nói rõ trạng thái lúc đó là không xác định. Em chỉ gắn listener quan sát để ghi một dòng log đồng bộ, rồi để Node thoát và Docker hay systemd khởi động lại. Phần quan trọng là ngăn từ code: mọi lời gọi bất đồng bộ hoặc được chờ, hoặc là bắn rồi quên có chủ đích với nhánh bắt lỗi viết rõ — và luật lint chặn promise trôi nổi. Khi deploy, em bật shutdown hook để process đóng pool database gọn gàng."
Mẫu đúng — Laravel job. Theo docs Laravel 13: attribute #[Tries], #[MaxExceptions], #[Backoff],
#[FailOnTimeout]; method failed(?Throwable $e) chạy khi job thất bại hẳn; --timeout của worker phải ngắn hơn retry_after
vài giây.
use Illuminate\Queue\Attributes\{Backoff, FailOnTimeout, MaxExceptions, Tries};
#[Tries(5)]
#[MaxExceptions(3)] // 3 exception thật → fail, dù còn lượt thử
#[Backoff(30)]
#[FailOnTimeout] // báo cáo nặng: timeout thì fail, không chạy lại từ đầu
final class GenerateReport implements ShouldQueue
{
use Queueable;
public int $timeout = 300; // < retry_after (vd 330) và < --timeout của worker
public function __construct(public int $reportId) {}
public function handle(ReportBuilder $builder): void
{
$report = Report::findOrFail($this->reportId);
$path = $builder->build($report); // ghi file tạm rồi move: không để file dở dang
$report->update(['status' => 'ready', 'path' => $path]);
}
public function failed(?Throwable $e): void
{
Report::whereKey($this->reportId)->update(['status' => 'failed']); // UI hiện "thất bại, thử lại"
Storage::delete("tmp/report-{$this->reportId}.csv"); // dọn file dở
}
}
Cách nói khi phỏng vấn: "Với job xuất báo cáo, em đặt tối đa năm lần thử nhưng ba exception thật là dừng, khoảng chờ ba mươi giây giữa các lần, và timeout thì đánh dấu thất bại luôn vì chạy lại một báo cáo nặng từ đầu chỉ làm tải tệ hơn. Thời gian tối đa của job ngắn hơn thời gian queue coi job là mất, để không có hai worker cùng chạy một job. Và em luôn có hàm xử lý khi thất bại: chuyển trạng thái báo cáo sang thất bại để người dùng biết mà thử lại, và dọn file dở dang."
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ớp | Chặn được | Không chặn được | Chi phí / đánh đổi |
|---|---|---|---|
| Validate + giới hạn ở cửa (Nginx body size, FormRequest, ValidationPipe) | Thiếu/thừa/sai kiểu/quá cỡ (CWE-234/235/754), phần lớn null từ input | Lỗi từ phụ thuộc, dữ liệu xấu đã nằm trong DB/cấu hình (Google Cloud 2025: dữ liệu chính sách có trường trống) | Rẻ; giới hạn quá chặt chặn người dùng thật — cần số liệu thật (p99 kích thước upload) |
| Handler lỗi tập trung | Lộ thông tin qua response (CWE-209/550/756), lỗi không ai bắt ở request HTTP | Fail open trong catch cục bộ (lỗi đã bị nuốt trước khi tới handler), lỗi trong job/promise trôi nổi | Rẻ; cần đồng thuận cả team về định dạng lỗi |
| Quyết định fail open/closed theo bảng (ADR) | CWE-636 có hệ thống | Code không tuân bảng — cần review và test gây lỗi | Tốn thời gian thiết kế; fail closed nhiều thì availability giảm |
| Transaction + afterCommit/outbox + idempotency | Trạng thái nửa vời, trùng khi retry | Hệ thống ngoài không hỗ trợ idempotency key; saga nhiều service | Outbox thêm bảng + worker; lock làm giảm throughput |
| finally / API tự trả tài nguyên + TTL | Rò connection/lock/file (CWE-460) | Rò bộ nhớ do thiết kế (cache cục bộ không giới hạn) | Gần như miễn phí |
| Timeout + retry có điều kiện + cầu dao | Cascading failure, retry storm | Chọn giá trị thay thế khi cầu dao ngắt (vẫn là quyết định nghiệp vụ) | Ngưỡng sai → ngắt oan; thêm trạng thái phải theo dõi |
| Supervisor + crash-only (Node, worker) | Process ở trạng thái không xác định chạy tiếp | Request đang xử lý khi process chết (mất); crash loop nếu lỗi lặp với mọi input | Cần nhiều instance để một cái chết không thành downtime |
| Test gây lỗi (fault injection), stress test | Nhánh lỗi chưa từng chạy | Tình huống không ai nghĩ ra để test | Tốn công dựng; phải chạy trên staging giống production |
5.2 Fail open hay fail closed — ma trận quyết định thay cho cảm tính
1. Câu hỏi đúng không phải "mở hay đóng" mà là ba câu: (a) bước kiểm tra này bảo vệ cái gì — quyền truy cập/dữ liệu, hay tài nguyên/chất lượng dịch vụ? (b) cho qua sai thì thiệt hại có đảo ngược được không (một bài nộp quá hạn thì sửa được; dữ liệu cá nhân đã lộ thì không)? (c) có nguồn thay thế nào an toàn hơn cả hai không (nguồn gốc DB, cache cũ có hạn dùng, chế độ chỉ đọc)?
| Phụ thuộc hỏng | Lựa chọn khuyến nghị | Lý do |
|---|---|---|
| Kiểm quyền, xác thực token, chữ ký webhook, danh sách khoá | Fail closed (sau khi thử nguồn gốc) | Bảo vệ dữ liệu; cho qua sai không đảo ngược được |
| Rate limiter (Redis) | Fail open có cảnh báo, với route nhạy cảm (đăng nhập, OTP) cân nhắc closed | Bảo vệ tài nguyên; chặn hết = tự gây sự cố (bài Rate limit) |
| Kiểm quota / gói cước | Thường fail open có ghi nhận để đối soát sau | Google Cloud 2025 chọn hướng này cho Service Control; thiệt hại là tiền, đối soát được |
| Tính năng phụ: gợi ý, thống kê, thông báo realtime | Fail open = ẩn tính năng (graceful degradation) | Không liên quan an toàn; trang chính vẫn phục vụ |
| Ghi audit cho thao tác bắt buộc có audit (sửa điểm, xuất dữ liệu cá nhân) | Fail closed (audit cùng transaction) | Thiếu audit = vi phạm yêu cầu (Bài 9) |
| Quét virus file upload | Fail closed: giữ file ở trạng thái "chờ quét", không cho tải | Cho qua sai thì file độc đã phát tán |
| Feature flag "tắt X cho tenant Y" | Rơi về giá trị mặc định an toàn đã khai báo cho từng flag | Mặc định tuỳ flag: flag bật tính năng mới → mặc định tắt |
2. Hai trường phái thật sự tồn tại, và nên trình bày như vậy: phía bảo mật (OWASP, CWE-636) mặc định fail closed — "Always fail closed" là câu OWASP viết ở A09; phía SRE/availability mặc định giữ dịch vụ chạy — Google Cloud sau sự cố còn cam kết "improve our static analysis and testing practices to correctly handle errors and if need be fail open". Hai phía không mâu thuẫn khi tách được lớp quyết định quyền (đóng) khỏi lớp bảo vệ tài nguyên/chất lượng (mở có giám sát). Mâu thuẫn chỉ xảy ra khi một phụ thuộc làm cả hai việc — lúc đó tách nó ra là quyết định kiến trúc đáng giá.
3. Fail open luôn đi kèm ba thứ, thiếu một thì không còn "có chủ đích": log sự kiện có tên, metric đếm số request đi qua nhánh mở, và cảnh báo khi metric khác 0 quá N phút. Fail open âm thầm thì giống hệt lỗ hổng.
5.3 Bắt tại chỗ hay để nổi lên — hai trường phái
1. OWASP A10 viết "catch every possible system error directly at the place where they occur and then handle it" và đồng thời yêu cầu "a global exception handler".
Trường phái "crash-only" / "let it crash" (Erlang, và chính docs Node về uncaughtException) nói điều có vẻ ngược lại: đừng cố cứu một trạng thái không xác định,
hãy để nó chết và khởi động lại sạch.
2. Cách dung hoà: phân loại lỗi. Lỗi nghiệp vụ dự đoán được (không đủ điểm, hết hạn nộp, không tìm thấy) — xử lý tại chỗ, trả 4xx có nghĩa.
Lỗi vận hành của phụ thuộc (timeout, mất kết nối) — xử lý tại chỗ theo bảng 5.2 (thử nguồn khác, retry có điều kiện, từ chối). Lỗi lập trình
(TypeError, null không lường trước, assert sai) — không bắt tại chỗ; để nổi lên handler toàn cục (HTTP) hoặc làm chết process (Node) để lỗi được thấy và
sửa. "Bắt tại chỗ" của OWASP hiểu đúng là "xử lý có nghĩa tại nơi có đủ ngữ cảnh để xử lý", không phải "bọc try/catch quanh mọi thứ".
5.4 Nếu chỉ có một sprint: thứ tự ưu tiên
- Kiểm
APP_DEBUG=false/NODE_ENV=productionvà handler tập trung trả thông điệp chung + request id — rẻ nhất, đóng ngay CWE-209. - Grep mọi
catchtrong module auth, phân quyền, thanh toán, webhook, chấm điểm: nhánh nào trả giá trị "cho qua" → sửa thành fail closed. - Timeout cho mọi lời gọi ra ngoài (HTTP, Redis, DB) — một danh sách ngắn trong cấu hình.
- Bật luật lint promise trôi nổi; kiểm
tries/timeout/failed()của các job quan trọng. - Viết bảng 5.2 cho hệ thống của mình thành ADR, rồi mới tới cầu dao và fault injection.
6. Phát hiện & vận hành
6.1 Code review: mẫu cần soi
catchrỗng, hoặc chỉ log rồi chạy tiếp, trong code bảo mật — chặn merge.catchtrảtrue,[],null, "allow" — hỏi "giá trị này nghĩa là cho qua hay chặn?".catch (\Throwable)/catch (err)giữa logic nghiệp vụ (CWE-396) — hỏi "ở đây xử lý được lỗi loại nào?".$e->getMessage(),err.message,err.stacktrong response.- Gọi hàm trả
false/nullkhi lỗi (lock, cache add, file,json_decode) mà không kiểm. beginTransaction()thủ công; thao tác trong callback transaction dùng repository/connection khác.- Dispatch job/mail/gọi API trong transaction mà không
afterCommit/outbox. - Lời gọi ra ngoài không có timeout; retry không điều kiện; retry ở nhiều tầng.
- Promise không
await;process.on('uncaughtException')không thoát. switchkhông default / không kiểm đủ nhánh trên giá trị liên quan quyền.- Input số không có trần; query không có
limit; upload không có giới hạn ở Nginx.
6.2 Công cụ tự động phủ được gì
1. SAST/lint bắt tốt phần cú pháp: catch rỗng, bắt quá rộng, promise trôi nổi (typescript-eslint có luật no-floating-promises và
no-misused-promises — theo hiểu biết, chưa tra docs), switch thiếu nhánh (TypeScript never, PHPStan ở mức cao báo match không đủ
nhánh — chưa verify). Không bắt được phần ngữ nghĩa: return false trong catch là "cho qua" hay "chặn" phụ thuộc tên hàm và ngữ cảnh.
Đó là lý do Avg Coverage của A10 chỉ 37,95%.
2. DAST gửi input sai kiểu/quá cỡ/thiếu trường và theo dõi response 500 có stack trace — tốt cho CWE-209/550/756. Không bắt được fail open vì DAST không điều khiển được Redis của mình.
3. Fault injection (chủ động gây lỗi) là công cụ duy nhất kiểm được nhánh fail open/closed: trên staging, chặn Redis, làm DB chậm, cắt mạng tới đối tác, rồi chạy bộ test tích hợp và kiểm "tài khoản bị khoá vẫn bị chặn", "pool connection không cạn", "không có giao dịch nửa vời". Bài học Google Cloud 2025 là đúng chỗ này: nhánh lỗi chưa từng chạy cho tới khi gặp dữ liệu thật. Cách dựng công cụ cụ thể nằm ngoài phạm vi bài (không lab).
4. Test đơn vị cho nhánh lỗi: mock phụ thuộc ném lỗi, assert kết quả là 403/503 chứ không phải 200, assert có log sự kiện, assert tài nguyên được trả.
Đây là test rẻ nhất mà hiếm team viết — đo bằng tỉ lệ catch có test phủ.
6.3 Vận hành: con số cần theo dõi
- Tỉ lệ 5xx theo endpoint và theo loại exception — Cheat Sheet: "Ensure that applications are monitored for 5xx errors which are a good indication of the application failing for some sets of inputs". Một endpoint đột nhiên có 5xx với một loại input là dấu hiệu có người đang dò.
- Số lần đi qua nhánh fail open (counter có tên theo phụ thuộc) — khác 0 kéo dài là sự cố.
- Số lần process khởi động lại (Node, worker) — restart liên tục = crash loop; tăng đột biến sau deploy = lỗi mới.
- failed_jobs tăng theo loại job; tuổi job cũ nhất trong queue (bài Scale out worker).
- Trạng thái cầu dao theo phụ thuộc; số lần ngắt mỗi giờ.
- Pool: connection đang dùng / tối đa, số request chờ connection, số lock đang giữ quá lâu, dung lượng thư mục tạm.
- Lỗi trùng bị gộp: nếu handler throttle báo cáo lỗi (4.1), theo dõi cả số lỗi bị bỏ — nếu không, việc gộp che mất quy mô sự cố.
Cloudflare 18/11/2025 thêm một bài học vận hành ít ai nghĩ tới: trong sự cố, latency tăng một phần vì "large amounts of CPU being consumed by our debugging and observability systems, which automatically enhance uncaught errors with additional debugging information". Chính đường xử lý lỗi cũng cần giới hạn — đó là lý do gộp lỗi trùng và giới hạn số báo cáo mỗi phút ở 4.1 không chỉ để log gọn.
7. Áp dụng vào hệ thống trong CV
Dữ kiện từ cv-v3.md; chỗ có dấu * là giả định, CV không nêu. Giá trị Moodle đọc từ config-dist.php nhánh MOODLE_405_STABLE; CV không
nói LMS Uni chạy bản Moodle nào*, nên phải kiểm lại trên bản thật.
7.1 LMS Uni — Moodle trên 5 app node, MySQL primary/replica, Redis tách instance, worker nhiều VPS, student portal NestJS
Redis session chậm — kịch bản dễ bị hỏi nhất. CV nêu Redis tách instance riêng cho session, cache và distributed lock.
config-dist.phpcủa Moodle ghi các mặc định của Redis session handler:session_redis_acquire_lock_timeout = 120("Default is 2 minutes"),session_redis_lock_retry = 100ms ("After 5 seconds it will throttle down to once per second"),session_redis_connection_timeout = 3,session_redis_maxretries = 3. Nghĩa là: nếu instance session chậm hoặc một request giữ lock phiên quá lâu, request khác của cùng phiên có thể chờ tới 2 phút. Tính minh hoạ với số của CV: cao điểm ~400 request/giây, giả sử 5 node × 40 worker PHP-FPM = 200 worker*; nếu chỉ 1% request (4/giây) bị kẹt 120 giây thì sau 50 giây đã có 200 request kẹt — toàn bộ pool đứng. Đó là kịch bản #1 của OWASP (tài nguyên bị giữ tới cạn) dưới dạng session lock. Câu trả lời: hạ thời gian chờ lock phiên xuống mức có nghĩa với UX (vài giây*), theo dõi số worker bận, và chấp nhận rằng session store hỏng thì người dùng phải đăng nhập lại — đó là fail closed, đúng hướng. Moodle xử lý cụ thể thế nào khi instance session chết hẳn — chưa verify.Redis distributed lock hỏng. Job chấm điểm, gửi thông báo trên nhiều VPS dùng lock để không chạy trùng. Lock store chết thì: không lấy được lock → không chạy, đẩy lại hàng đợi (fail closed) cho chấm điểm, vì chấm hai lần làm hỏng điểm; với gửi thông báo có idempotency theo (user, sự kiện) thì có thể chạy. Nếu code đang kiểu "không lấy được lock thì cứ chạy"* là đúng CWE-636 trong chính tầng chống trùng.
Redis cache hỏng. Cache là tối ưu, không phải kiểm soát — mở (đọc thẳng MySQL) là hợp lý, nhưng 400 request/giây dồn về primary/replica cùng lúc là cache avalanche (bài Cache nâng cao). MUC của Moodle tự rơi về nguồn khi store lỗi hay ném lỗi — chưa verify.
MySQL primary/replica failover. Trong khoảng chuyển primary, lệnh ghi lỗi: giao dịch nộp bài phải rollback trọn vẹn và người dùng nhận thông điệp chung "thử lại", không phải trang lỗi có tên host DB. Retry tự động chỉ cho thao tác idempotent — nộp bài thì cần khoá chống trùng. Replica lag là tình huống bất thường thứ hai: sinh viên nộp xong, trang kế tiếp đọc từ replica chưa kịp cập nhật, hiện "chưa nộp", sinh viên bấm nộp lại. Phòng thủ: đọc từ primary ngay sau khi ghi (cùng request hoặc theo phiên vài giây), và theo dõi độ trễ replica. CV không nói ai/cái gì promote replica và có chống hai primary cùng nhận ghi không* — interviewer có thể hỏi.
Worker nhiều VPS, backlog ~40.000 job cuối hạn. Ba điểm A10: (1)
--timeoutcủa worker <retry_after, nếu không job chấm điểm dài có thể chạy hai lần (docs Laravel, mục 3.8 — nếu worker là Laravel*; nếu là cron/adhoc task của Moodle thì cơ chế khác, chưa verify); (2) job xuất báo cáo có trạng thái "thất bại" rõ ràng để người dùng không bấm tạo lại liên tục (CV: báo cáo chuyển sang queue + email khi xong); (3) đối tác gửi thông báo sập thì cầu dao kiểuThrottlesExceptions, không để 40.000 job đốt hết lượt thử.Student portal NestJS dùng chung database. Node: một promise reject không ai bắt làm chết cả process và mọi request đang phục vụ — portal phải chạy nhiều instance sau LB và có restart policy*. Filter toàn cục (4.1) để lỗi query không lộ cấu trúc bảng của Moodle. Rủi ro riêng của "dùng chung DB": Moodle nâng cấp đổi schema → query của portal gặp lỗi mà portal không lường trước; nó phải lỗi rõ ràng (500 + cảnh báo) chứ không trả danh sách rỗng như thể sinh viên không có khoá học nào.
Debug trên production.
config-dist.phpghi$CFG->debugmức DEBUG_DEVELOPER và$CFG->debugdisplay = 1là "NOT FOR PRODUCTION SERVERS!". Với 5 node, kiểm cả cấu hình trongconfig.phplẫn trong bảng cấu hình (đặt qua giao diện admin)* — một admin bật debug để điều tra rồi quên tắt là CWE-209 trên toàn site.
7.2 LMS Micro-learning — Laravel + Livewire, NestJS, PostgreSQL, Redis, MongoDB, MinIO, 4 trường
Idempotency key Redis SET NX + unique constraint — câu chuyện fail open an toàn. CV nêu hai lớp: Redis chặn trùng nhanh, unique constraint ở PostgreSQL là chốt cuối. Vì có lớp hai, khi Redis hỏng có thể mở lớp Redis (bỏ qua, ghi cảnh báo) mà không mất đảm bảo — constraint vẫn chặn bản ghi trùng. Điều kiện: lỗi vi phạm unique phải được map thành phản hồi idempotent (trả kết quả lần trước hoặc 409), không phải 500. Đây là ví dụ đẹp cho câu "fail open chỉ an toàn khi có lớp khác giữ bất biến".
Cache Redis + lock khi rebuild (chống stampede). Lock rebuild nằm trên Redis*; Redis chết thì mọi request cùng rebuild → PostgreSQL nhận cả cơn bão. Cần giới hạn đồng thời ở tầng ứng dụng hoặc trả dữ liệu cũ khi có thể.
MinIO + transaction. Upload lên MinIO xong rồi transaction DB rollback → object mồ côi (kịch bản #1 dạng chậm: tốn dung lượng). Thứ tự an toàn: ghi DB trạng thái "đang tải" → upload → cập nhật "xong"; job dọn object không có bản ghi "xong" sau N giờ*.
4 trường trên một codebase. Tenant id
nulldo thiếu context (job chạy ngoài request, lệnh artisan) không được hiểu là "mọi trường" — global scope phải ném lỗi khi thiếu tenant thay vì bỏ điều kiện* (mục 3.3). Thông điệp lỗi không được nhắc tên trường khác.Blue-green + payload job tương thích ngược (CV có nêu). Lúc chuyển traffic, worker bản mới có thể nhận job do bản cũ tạo. Job không hiểu payload phải fail rõ ràng vào
failed_jobs(rồi retry sau khi sửa) thay vì dùng giá trị mặc định cho trường thiếu — đúng tinh thần CWE-234.Livewire. Exception trong action trả về trình duyệt thế nào khi debug tắt — chưa verify; vẫn đi qua handler của Laravel nên 4.1 áp dụng*.
7.3 Trả lời mẫu
Cách nói khi phỏng vấn: "Ở LMS Uni em có ba Redis instance cho session, cache và lock, và em quyết định hành vi khi hỏng cho từng cái chứ không để mặc định. Cache hỏng thì mở, đọc thẳng database nhưng có giới hạn đồng thời để không thành bão. Lock hỏng thì đóng: job chấm điểm không chạy mà quay lại hàng đợi, vì chấm hai lần tệ hơn chấm muộn vài phút. Session hỏng thì người dùng đăng nhập lại — cũng là đóng; và em hạ thời gian chờ lock phiên của Moodle, vì mặc định hai phút đủ để vài phần trăm request kẹt làm đầy toàn bộ worker. Ở LMS Micro-learning, em có ví dụ fail open an toàn: khoá chống trùng ở Redis hỏng thì bỏ qua lớp đó, vì unique constraint ở PostgreSQL vẫn giữ được bất biến. Nguyên tắc chung: chỉ mở khi có một lớp khác giữ đảm bảo, và mọi nhánh mở đều có log, metric và cảnh báo."
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
1. Google Cloud, 12/06/2025 — null pointer trong nhánh chưa từng chạy, rồi retry storm. Theo báo cáo sự cố chính thức: ngày 29/05/2025 một tính năng kiểm quota mới được thêm vào Service Control (thành phần kiểm quyền và quota cho mọi API request); code "did not have appropriate error handling nor was it feature flag protected". Ngày 12/06 một thay đổi chính sách có "unintended blank fields" được ghi vào Spanner và "replicated globally within seconds"; nhánh code mới đọc trường trống, gặp null pointer, binary "go into a crash loop" ở mọi vùng. Khoảng 25 phút sau "red-button" tắt nhánh đó sẵn sàng, 40 phút thì triển khai xong; nhưng ở us-central1 các task khởi động lại cùng lúc tạo "a herd effect" lên Spanner vì "did not have the appropriate randomized exponential backoff" — mất tới khoảng 2 giờ 40 phút. Ánh xạ A10: CWE-476 (null dereference), dữ liệu bất thường không được validate (3.3), nhánh lỗi chưa từng chạy (mục 2 điểm 1), retry không jitter (3.7). Biện pháp Google công bố: tính năng tách module và fail open nếu kiểm tra hỏng, mọi thay đổi binary quan trọng sau feature flag tắt mặc định, lan truyền dữ liệu toàn cầu từng bước, kiểm toán backoff ngẫu nhiên. Một chi tiết vận hành: trang trạng thái của chính Google Cloud chậm khoảng 1 giờ vì hạ tầng của nó cũng bị ảnh hưởng.
2. Cloudflare, 18/11/2025 — có giới hạn, nhưng vượt giới hạn thì crash. Theo blog chính thức: một thay đổi phân quyền trong cụm ClickHouse làm truy vấn sinh
"feature file" cho Bot Management trả về dòng trùng, file "doubled in size". Proxy có giới hạn 200 feature (đang dùng ~60) vì cấp phát bộ nhớ trước; file mới vượt 200,
code Rust của FL2 gọi unwrap() trên giá trị lỗi → "thread fl2_worker_thread panicked: called Result::unwrap() on an Err value" → trả 5xx. Proxy bản cũ FL không
crash nhưng cho mọi request bot score bằng 0 — khách hàng có luật chặn bot bị chặn nhầm hàng loạt (một kiểu hỏng im lặng sai hướng). Vì file được sinh lại 5 phút một lần
từ các node đã/chưa cập nhật, hệ thống lúc hỏng lúc lành, khiến đội ban đầu nghi là DDoS. Bắt đầu 11:20 UTC, lưu lượng chính trở lại khoảng 14:30, mọi thứ bình thường lúc 17:06.
Ánh xạ A10: giới hạn tài nguyên là đúng ("Nothing ... should be limitless"), cái sai là nhánh vượt giới hạn — lẽ ra phải giữ cấu hình tốt gần nhất và báo động, thay vì
panic. Biện pháp Cloudflare nêu: "Hardening ingestion of Cloudflare-generated configuration files in the same way we would for user-generated input", thêm kill switch toàn
cục, và "Eliminating the ability for core dumps or other error reports to overwhelm system resources".
3. Ba kịch bản của OWASP (upload rò tài nguyên → DoS; lỗi DB lộ ra → trinh sát cho SQL injection; giao dịch nhiều bước bị cắt giữa chừng → mất tiền hoặc chuyển nhiều lần) đã được phân tích ở 3.2, 3.5, 3.6. OWASP còn dẫn một ví dụ thực tế về mức phạt 62 triệu USD do lỗi con người và kiểm soát nội bộ (bài trên firstreference.com) — bài này chưa đọc nguồn đó.
Bài học chung của hai sự cố: cả hai đều bắt đầu từ dữ liệu nội bộ (chính sách, file cấu hình) chứ không phải input của người dùng, và cả hai đều lan toàn cầu trong vài giây. Dữ liệu do hệ thống của mình sinh ra cũng phải được validate như input bên ngoài, và nhánh "dữ liệu không hợp lệ" phải giữ trạng thái tốt gần nhất thay vì crash hoặc chạy với giá trị rỗng.
8.2 Anti-pattern thường gặp khi review
- "Pokemon exception handling" (gotta catch 'em all):
catch (\Throwable)ở mọi tầng, mỗi tầng trả một giá trị mặc định khác nhau. - Log rồi đi tiếp: có dấu vết nhưng hành vi vẫn là fail open.
- Giá trị mặc định "tiện":
?? []cho danh sách quyền,?? truecho cờ "được phép",?? 0cho số tiền cần thu. - Retry mọi thứ: retry cả 400/403/422 (không bao giờ thành công), retry POST không có idempotency key, retry ở ba tầng.
- Timeout mặc định: không ai biết timeout hiện tại của client Redis, HTTP, DB là bao nhiêu.
- Chép ví dụ docs nguyên văn: ví dụ minh hoạ API (như
catchchỉ rollback không ném lại của TypeORM) không phải code production. - Thông điệp lỗi khác nhau làm lộ trạng thái: "email không tồn tại" vs "sai mật khẩu"; 403 vs 404 cho tài nguyên của người khác.
uncaughtExceptiongiữ process sống: chạy tiếp trong trạng thái không xác định — docs Node ví như rút dây nguồn khi đang nâng cấp máy, chín lần không sao, lần thứ mười hỏng.- Không có trạng thái "thất bại" trong nghiệp vụ: báo cáo, thanh toán, chấm điểm chỉ có "đang xử lý" và "xong".
- Không giới hạn:
per_page, kích thước upload, độ sâu JSON, số phần tử mảng, độ dài hàng đợi, số job retry — mỗi cái là một cửa DoS và một hoá đơn.
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| A10:2025 là mục mới — nó đến từ đâu? | Gom 24 CWE trước đây coi là "code quality" chung chung, cụ thể hoá thành xử lý tình huống bất thường: lỗi lộ thông tin, input bất thường, lỗi không được nhận ra, fail open, dọn dẹp sai. Điểm rất sát X01 Lack of Application Resilience. |
| Fail open và fail closed — chọn thế nào? | Theo cái bước kiểm tra bảo vệ: quyền/dữ liệu/chữ ký → đóng; tài nguyên/tính năng phụ → mở có log + metric + cảnh báo. Thử nguồn gốc trước khi chọn. Mở chỉ an toàn khi có lớp khác giữ bất biến. |
Vì sao không trả $e->getMessage() cho client? | Nó chứa SQL, tên bảng, đường dẫn, phiên bản — thông tin trinh sát (CWE-209). Client nhận mã trạng thái + câu chung + request id; chi tiết ở log server. |
| Bắt lỗi tại chỗ hay dùng handler toàn cục? | Cả hai, theo loại: lỗi nghiệp vụ và lỗi phụ thuộc xử lý tại nơi có ngữ cảnh; lỗi lập trình để nổi lên handler toàn cục (hoặc để process chết). Handler toàn cục là lưới an toàn, không thay xử lý tại chỗ. |
| Giao dịch hỏng giữa chừng thì sao? | Rollback trọn vẹn rồi làm lại từ đầu (OWASP: đừng cố khôi phục giữa chừng). Tác dụng phụ ngoài DB phát sau commit (afterCommit/outbox). Timeout = "không biết", retry kèm idempotency key. |
Node process có nên bắt uncaughtException để chạy tiếp? | Không. Docs Node: không an toàn để tiếp tục; chỉ dọn dẹp đồng bộ rồi thoát, để supervisor khởi động lại. Ngăn bằng await đủ + lint promise trôi nổi + nhiều instance. |
| Retry có hại gì? | Nhân tải (3 tầng × 3 lần = 27 lời gọi), gây herd khi phục hồi (Google Cloud 2025), trùng thao tác không idempotent. Retry một tầng, có điều kiện, backoff + jitter, có ngân sách. |
| Circuit breaker giải quyết gì và không giải quyết gì? | Biến "chậm" thành "lỗi nhanh", trả worker về pool, cho phụ thuộc thời gian hồi phục. Không quyết định giùm khi ngắt thì từ chối hay dùng giá trị thay thế; ngưỡng sai thì ngắt oan. |
| Làm sao test nhánh lỗi? | Unit test mock phụ thuộc ném lỗi, assert 403/503 + có log + tài nguyên được trả; fault injection trên staging (chặn Redis, làm DB chậm) rồi chạy test tích hợp; stress test cho giới hạn. |
| Queue job lỗi mãi thì sao? | Giới hạn tries + maxExceptions, backoff, FailOnTimeout cho job không được chạy lại, worker timeout < retry_after, method failed() đưa trạng thái nghiệp vụ về "thất bại" và dọn tài nguyên; cảnh báo khi failed_jobs tăng. |
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:
- Laravel: hành vi
rendertrảnull→ render mặc định, và JSON mặc định khiapp.debugbật/tắt — đọc sourceHandler.php13.x (renderViaCallbacks,convertExceptionToArray), chưa chạy.throttle()nhậnLimit/LotteryvàdontReportDuplicates()— đọc sourceConfiguration/Exceptions.phpvàHandler.php, chưa chạy; khoá củaLimit::perMinutekhi không gọiby()chưa kiểm. - Laravel HTTP client: không ném exception với 4xx/5xx khi chưa gọi
throw(), timeout tổng mặc định, và cáchretry(..., when:)phối hợp vớithrowkhi response là 5xx — theo docs/hiểu biết, chưa chạy. - Laravel:
\RedisExceptionlà lớp exception khi Redis (phpredis) mất kết nối qua facade; tuỳ chọn timeout của kết nối Redis trongconfig/database.php— chưa tra, chưa chạy. - Laravel queue attributes (
Tries,MaxExceptions,Backoff,FailOnTimeout) và chữ kýfailed(?Throwable)trên job — theo docs 13.x qua Context7 (ví dụ docs dùngfailedtrên listener), chưa chạy. - PHP:
DivisionByZeroErrorvới toán tử/,UnhandledMatchErrorcủamatch— theo PHP manual/hiểu biết, chưa chạy trong bài. - NestJS:
app.useBodyParser('json', { limit })dùng để hạ giới hạn (docs chỉ nói nâng);host.getType()trong filter; guard trảfalse→ 403 — theo docs, chưa chạy. - TypeORM: tuỳ chọn
lock: { mode: 'pessimistic_write' }trongfind;dataSource.transactionném lại lỗi sau rollback — theo hiểu biết + docs transactions, chưa chạy. - Node:
AbortSignal.timeout,fetchkhông reject với 4xx/5xx — theo hiểu biết về Fetch API;uncaughtExceptionMonitorvà chế độ--unhandled-rejections=throwmặc định — đã tra docs Node; hành vi chết process trên Node 22 có lab ở bài Microtask. - typescript-eslint
no-floating-promises, PHPStan kiểm đủ nhánhmatch, thư viện circuit breakeropossum— chưa tra docs. - Moodle: các mặc định Redis session lấy từ
config-dist.phpMOODLE_405_STABLE; hành vi thật khi instance session/cache/lock chết, MUC có tự rơi về nguồn không, Livewire hiển thị exception thế nào khi debug tắt — chưa verify. Phép tính "1% request kẹt 120 giây làm đầy 200 worker sau 50 giây" dùng số worker giả định. - RFC 7807 đã được thay bằng RFC 9457 — theo hiểu biết chung, chưa đọc.
- Sự cố: chi tiết lấy từ báo cáo chính thức của Google Cloud (trang incident) và blog Cloudflare; bài của firstreference.com mà OWASP dẫn chưa đọc.
- Các con số ví dụ (240 nhánh lỗi, pool 20 connection, 50 worker và 400 request/giây, timeout 200 ms / 1 / 3 / 5 giây, ngưỡng cầu dao 5 lần / 30 giây, 10 lỗi / 5 phút, giới hạn 100 bản ghi/trang, 500 MB video) là minh hoạ, không phải số đo. Các điểm có dấu * ở mục 7 là giả định về hệ thống trong CV.
Nguồn
- OWASP Top 10:2025 — A10 Mishandling of Exceptional Conditions: top10.owasp.org (Background, bảng điểm, Description, How to prevent, 3 kịch bản, danh sách 24 CWE; tham khảo ASVS 5.0 V16.5 Error Handling, MASVS-RESILIENCE)
- OWASP Cheat Sheet: Error Handling (trinh sát, handler toàn cục, response chung, RFC 7807, 4xx/5xx, giám sát 5xx); A09:2025 cho câu "Always fail closed": top10.owasp.org
- CWE: 209, 252, 390, 396, 460, 476, 636, 755 (định nghĩa theo tên trên trang OWASP)
- Laravel 13 docs (Context7
/laravel/docs): Errors (render, shouldRenderJsonWhen), Queues (Tries, MaxExceptions, Backoff, FailOnTimeout, --timeout và retry_after, ThrottlesExceptions, afterCommit), Database — transactions (attempts khi deadlock), HTTP Client (retry, connectTimeout, ConnectionException), Events (failed trên listener queued), Mail (afterCommit cho mailable) - Source
laravel/framework13.x: Foundation/Exceptions/Handler.php (renderViaCallbacks,convertExceptionToArray,throttle), Foundation/Configuration/Exceptions.php (context, throttle, dontReportDuplicates, render), Cache/Lock.php (get()+finally release()) - NestJS docs (Context7
/nestjs/docs.nestjs.com): Exception filters (filter mặc định,@Catch()+ HttpAdapterHost, APP_FILTER vs useGlobalFilters, ghi chú http-errors/expose từ v12.2), Validation (whitelist, forbidNonWhitelisted), Raw body (giới hạn body Express 100kb / Fastify 1 MiB), Lifecycle events (onApplicationShutdown) - TypeORM docs (Context7
/typeorm/typeorm): Transactions (dataSource.transaction, QueryRunner + release trong finally) - Node.js docs (Context7
/nodejs/node): process — 'uncaughtException' (cảnh báo dùng đúng, uncaughtExceptionMonitor), --unhandled-rejections, changelog Node 15 (mặc địnhthrow) - Moodle
MOODLE_405_STABLE: config-dist.php (Redis session handler: acquire_lock_timeout, lock_retry, connection_timeout, maxretries; debug/debugdisplay "NOT FOR PRODUCTION SERVERS") - Sự cố: Google Cloud — Incident report 12/06/2025 (Service Control); Cloudflare — Cloudflare outage on November 18, 2025
- Trong repo: Bài 0 — tổng quan (điểm tính lại 530,96 và X01 522,02), Bài 1 — A01, Bài 2 — A02, Bài 5 — A05, Bài 6 — A06, Bài 7 — A07, Bài 9 — A09, Rate limit phân tán, Vận hành rate limit, Microtask trong Node.js, Scale out queue worker, Cache nâng cao, Idempotency key ở Redis,
cv-v3.md
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 9 — A09 Security Logging and Alerting Failures · Bài 10 — A10 Mishandling of Exceptional Conditions (bài này, bài cuối của series)