Bài 6 — A06:2025 Insecure Design: khi control chưa từng được thiết kế — threat modeling, business logic, workflow, race condition
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 5 — A05 Injection · Bài 6 — A06 Insecure Design (bài này) · Tiếp: Bài 7 — A07 Authentication Failures →
Năm mục trước của series có chung một dạng: control có, nhưng cài sai — query nối chuỗi thay vì bind, hash bằng MD5 thay vì Argon2id, debug bật trên production. Insecure Design (thiết kế không an toàn) là mục duy nhất mà câu hỏi không phải "cài control đúng chưa" mà là "control đó có tồn tại không". Một luồng nộp bài kiểm tra hạn nộp ở trình duyệt, một API ghi danh đọc số chỗ còn lại rồi mới trừ, một tính năng gửi email mời học viên không có trần — code có thể sạch, đủ test, qua SAST không cảnh báo nào, vẫn hỏng, vì không ai từng quyết định hệ thống phải chặn hành vi đó. Câu hỏi interviewer muốn nghe ở mức Senior/Tech Lead: em làm gì trước khi viết code để control cần có được nghĩ tới, em biến luật nghiệp vụ thành invariant (bất biến — điều luôn phải đúng) mà database hoặc server thực thi được, và em kiểm chứng điều đó bằng test nào.
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 Cheat Sheet Series, ASVS 5.0, OWASP SAMM, docs Laravel 13, NestJS, TypeORM tra qua Context7) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở cuối bài. Các hành vi về khoá và unique index đã có lab thật ở các bài khác của KB (Chống double-submit, Optimistic locking) và được dẫn lại thay vì viết lại. Trọng tâm là phòng thủ: mỗi cơ chế được giải thích ở mức "giả định nào trong thiết kế bị sai và hệ quả là gì"; bài không chứa hướng dẫn khai thác.
1. Mục này là gì trong 2025
1.1 Định nghĩa: "missing or ineffective control design"
1. OWASP mô tả A06 là "a broad category representing different weaknesses, expressed as 'missing or ineffective control design'" — một nhóm rộng, gom các điểm yếu có chung bản chất: control (cơ chế kiểm soát) bị thiếu hoặc được thiết kế không đủ hiệu lực. Ngay sau đó OWASP chặn một cách hiểu sai phổ biến: "Insecure design is not the source for all other Top Ten risk categories" — không phải lỗ hổng nào cũng là "lỗi thiết kế" chỉ vì về lý thuyết thiết kế tốt hơn thì đã tránh được.
2. Phân biệt cốt lõi của mục này, OWASP viết thẳng: "there is a difference between insecure design and insecure implementation … they have different root causes, take place at different times in the development process, and have different remediations". Rồi câu đáng mang vào phỏng vấn nhất: "A secure design can still have implementation defects … An insecure design cannot be fixed by a perfect implementation as needed security controls were never created to defend against specific attacks." Dịch ý: thiết kế tốt vẫn có thể bị cài sai; nhưng thiết kế thiếu control thì cài hoàn hảo đến mấy cũng không vá được, vì thứ cần cài chưa bao giờ có trong spec.
| Trục | Design flaw (lỗi thiết kế — A06) | Implementation defect (lỗi cài đặt — A01–A05, A07…) |
|---|---|---|
| Câu hỏi chẩn đoán | Control này có trong yêu cầu/thiết kế không? | Control có trong thiết kế, code có làm đúng không? |
| Ví dụ LMS (minh hoạ) | Spec "sinh viên nộp bài trước hạn" không nói ai tính hạn, theo đồng hồ nào — server không kiểm hạn | Spec nói server kiểm hạn, code so sánh nhầm múi giờ |
| Xảy ra lúc | Viết yêu cầu, refinement, thiết kế | Viết code, cấu hình |
| Ai phát hiện rẻ nhất | Threat modeling, design review, PO/BA cùng dev | Code review, SAST, test |
| Cách sửa | Thêm control mới, đổi luồng, đôi khi bỏ cả tính năng | Sửa dòng code / cấu hình |
| Công cụ tự động phủ được | Rất ít (xem mục 6.2) | Nhiều |
3. OWASP nêu một nguyên nhân gốc: "the lack of business risk profiling inherent in the software or system being developed, and thus the failure to determine what level of security design is required" — không đánh giá rủi ro nghiệp vụ nên không biết cần thiết kế an toàn tới mức nào. Hệ quả thực tế: một tính năng "điểm thưởng giới thiệu bạn bè" được thiết kế với cùng mức cẩn trọng như trang "đổi ảnh đại diện", trong khi một cái phát ra giá trị (tiền, điểm, quyền) còn cái kia thì không.
4. OWASP chia việc có một thiết kế an toàn thành ba phần, và cả ba đều là việc trước khi hoặc bao quanh việc viết code:
- Requirements and Resource Management — thu thập và thương lượng yêu cầu với bên nghiệp vụ, kể cả yêu cầu bảo vệ về confidentiality, integrity, availability, authenticity (bảo mật, toàn vẹn, sẵn sàng, xác thực nguồn gốc) cho từng loại dữ liệu và "the expected business logic"; tính tới mức độ phơi ra Internet và việc có cần tách tenant (khách thuê — ở đây là từng trường) "beyond those needed for access control"; và thương lượng ngân sách cho cả hoạt động bảo mật.
- Secure Design — "a culture and methodology": threat modeling lồng vào các buổi refinement, xác định "the correct flow and failure states" trong từng story, phân tích các giả định của luồng đúng và luồng lỗi, ghi kết quả vào story. OWASP chốt: "Secure design is neither an add-on nor a tool that you can add to software."
- Secure Development Lifecycle — SDL, secure design pattern, paved road (con đường lát sẵn — thành phần dựng sẵn đã an toàn mặc định), thư viện component an toàn, tooling, threat modeling, và — điểm mới 2025 — "incident post-mortems that are used to improve the process". OWASP gợi ý dùng OWASP SAMM để có khung.
1.2 Thay đổi so với 2021
1. Hạng: 4 → 6. OWASP giải thích: A02 Security Misconfiguration và A03 Software Supply Chain Failures "leapfrog" (nhảy qua) mục này; đồng thời ghi nhận "noticeable improvements in the industry related to threat modeling and a greater emphasis on secure design". Cần đọc kỹ: tụt hạng là tương đối (hai mục khác lên), không có nghĩa lỗi thiết kế đã bớt — số occurrences còn tăng gần gấp ba (bảng 1.3).
2. Mô tả mở rộng. Bản 2025 thêm câu nói rõ phạm vi bao gồm "flaws in the business logic of an application, e.g. the lack of defining unwanted or unexpected state changes inside an application" — tức là thiếu định nghĩa về những chuyển trạng thái không mong muốn. Đây là lý do bài này dành phần lớn cho workflow và race condition. Bản 2025 cũng thêm một đoạn về trách nhiệm của chính developer: "Often self-responsibility of developers is underappreciated".
3. Danh sách "How to prevent" đổi hai chỗ. Thêm "Use threat modeling as an educational tool to generate a security mindset". Bỏ dòng "Limit resource consumption by user or service" có trong bản 2021 — nhưng CWE-799 (kiểm soát tần suất tương tác) vẫn nằm trong danh sách CWE, và kịch bản số 2, số 3 vẫn là chuyện giới hạn khối lượng; nên trong thực tế giới hạn nghiệp vụ vẫn thuộc phạm vi A06 (mục 3.1). Lý do OWASP bỏ dòng đó không được nêu — chưa verify.
4. CWE: 40 → 39. Đối chiếu hai danh sách chính thức (bản markdown trên GitHub của OWASP):
| Chiều thay đổi | CWE | Ý nghĩa |
|---|---|---|
| Có ở 2021, không còn ở A06:2025 (11) | CWE-209, 213, 235, 257, 280, 430, 579, 650, 840, 927, 1173 | CWE-209 (thông báo lỗi lộ thông tin) nay thuộc A10 Mishandling of Exceptional Conditions (xem Bài 0); CWE-840 "Business Logic Errors" là CWE dạng category chung — bị bỏ, nhưng CWE-841 cụ thể hơn vẫn còn |
| Mới vào A06:2025 (10) | CWE-286, 362, 382, 436, 454, 628, 676, 693, 1022, 1125 | Nổi bật: CWE-362 race condition chính thức vào A06 — khớp với việc mô tả nhấn mạnh chuyển trạng thái; CWE-1125 Excessive Attack Surface; CWE-693 Protection Mechanism Failure |
Hai danh sách được đối chiếu bằng máy (lấy mọi mã CWE trong hai file markdown, so tập hợp): 40 − 11 + 10 = 39, khớp số OWASP công bố. Hai chi tiết dễ hỏi: CWE-444 (HTTP request smuggling) nằm ở A06 cả hai năm, không phải ở A02 như nhiều người đoán; CWE-73 (đường dẫn file do bên ngoài kiểm soát) cũng có mặt ở A06 cả 2021 lẫn 2025 — trả lời luôn câu "CWE-73 đi đâu" còn để ngỏ ở Bài 5.
1.3 Bảng điểm chính thức
| Chỉ số | A04:2021 Insecure Design | A06:2025 Insecure Design | Đọc thế nào |
|---|---|---|---|
| CWE mapped | 40 | 39 | Phạm vi gần như giữ nguyên |
| Max incidence rate | 24,19% | 22,18% | Ứng dụng tệ nhất có khoảng 1/5 số lần test dính một CWE của mục |
| Avg incidence rate | 3,00% | 1,86% | Giảm — một phần do tử số/mẫu số đổi khi coverage tăng |
| Max coverage | 77,25% | 88,76% | Nhiều ứng dụng được test cho mục này hơn |
| Avg coverage | 42,51% | 35,18% | |
| Avg weighted exploit | 6,46 | 6,96 | Dễ khai thác hơn (thang 10) |
| Avg weighted impact | 6,78 | 4,05 | Tác động trung bình thấp hơn nhiều — chưa rõ do đổi CWE hay đổi dữ liệu CVSS |
| Total occurrences | 262.407 | 729.882 | Tăng ~2,8 lần |
| Total CVEs | 2.691 | 7.647 | Tăng ~2,8 lần |
Một chú ý khi trích số: OWASP nói thẳng ở Bài 0 rằng A06 "is beyond the scope of most forms of automated testing". Con số occurrences ở đây chủ yếu đến từ các CWE có dấu hiệu kỹ thuật (lưu mật khẩu rõ, upload không kiểm loại file, clickjacking — CWE-1021) chứ không phải từ lỗi business logic thuần, vốn hầu như không tool nào đếm được. Nói cách khác: phần quan trọng nhất của mục này là phần ít được đo nhất.
1.4 Gom 39 CWE theo cơ chế
| Nhóm (em tự gom) | CWE | Giả định sai chung | Bài liên quan |
|---|---|---|---|
| Business logic, workflow, tần suất | 841, 799, 362, 472, 642, 454, 602, 807, 183 | "Client sẽ đi đúng thứ tự, gửi đúng giá trị, không gửi song song, không gửi nhiều" | Mục 3.1–3.4 bài này |
| Trust boundary và phân tách | 501, 653, 1125, 419, 657, 693, 656 | "Dữ liệu đã vào trong hệ thống là đáng tin", "giấu đi là an toàn" | Mục 3.6, 3.8 |
| Quản lý quyền ở mức thiết kế | 266, 269, 286 | "Quyền cấp một lần là xong, không cần vòng đời" | Bài 1, RBAC |
| Thông tin nhạy cảm lưu/truyền sai chỗ do thiết kế | 256, 522, 311, 312, 313, 316, 525, 539, 598 | "Không ai đọc được chỗ này đâu" (log, cache trình duyệt, cookie lâu dài, query string) | Bài 4 |
| File, upload, diễn giải lệch giữa hai thành phần | 73, 434, 646, 436, 444 | "Đuôi file nói lên loại file", "proxy và app hiểu request giống nhau" | Bài 1 (đường dẫn file), Bài 2 |
| Giao diện đánh lừa người dùng | 451, 1021, 1022 | "Người dùng thấy đúng cái họ đang bấm" | Bài 2 (header chống frame) |
| Thực hành code nguy hiểm | 382, 628, 676 | "Hàm này tiện thì dùng" | Bài 5, Bài 10 |
Bài này đi sâu nhóm đầu và nhóm hai — phần chỉ A06 phủ. Các nhóm còn lại đã có bài riêng trong series, chỉ nhắc ở mức "vì sao nó là lỗi thiết kế" và link sang.
1.5 Ranh giới với các mục khác
Câu hỏi phân loại hữu ích nhất khi review hay viết postmortem: "nếu dev cài đúng 100% những gì spec yêu cầu, lỗi này còn không?". Còn → A06. Hết → lỗi cài đặt thuộc mục khác.
flowchart TD
Q1["Lỗi bảo mật vừa tìm thấy"] --> Q2{"Spec / thiết kế có yêu cầu<br/>control chặn hành vi này không?"}
Q2 -- "Không có" --> A06["A06 Insecure Design<br/>thêm control vào thiết kế"]
Q2 -- "Có nhưng không đủ<br/>(vd chỉ kiểm ở client)" --> A06
Q2 -- "Có và đủ" --> Q3{"Code / cấu hình làm đúng chưa?"}
Q3 -- "Sai cách kiểm quyền" --> A01["A01 Broken Access Control"]
Q3 -- "Sai cách dựng lệnh" --> A05["A05 Injection"]
Q3 -- "Sai cấu hình" --> A02["A02 Security Misconfiguration"]
Q3 -- "Sai khi lỗi xảy ra" --> A10["A10 Exceptional Conditions"]
Cách nói khi phỏng vấn: "Em phân loại bằng một câu hỏi: nếu dev làm đúng từng chữ trong spec thì lỗi còn không. Nếu còn, đó là lỗi thiết kế — control chưa bao giờ được nghĩ tới, và cách sửa là đổi spec, thêm control, đôi khi bỏ tính năng. Nếu hết, đó là lỗi cài đặt và thuộc mục khác như phân quyền hay injection. Phân biệt như vậy quan trọng vì nó quyết định sửa ở đâu: sửa một dòng code, hay sửa cách team viết yêu cầu."
Có ba vùng chồng lấn hay gây tranh cãi:
- A01 và A06: "quản lý được duyệt báo cáo chi phí, nhưng không được duyệt của chính mình" — Business Logic Cheat Sheet gọi đây là "authorization bugs in disguise" (lỗi phân quyền đội lốt). Nếu luật "không tự duyệt" chưa từng có trong spec → A06; nếu có mà endpoint quên kiểm → A01. Bài ABAC đã bàn cách diễn đạt các luật theo ngữ cảnh như vậy.
- A04 và A06: CWE-311/312 (không mã hoá, lưu rõ) nằm ở A06 vì quyết định "dữ liệu này có cần mã hoá không" là quyết định thiết kế; còn chọn sai thuật toán, sai mode thì là A04.
- A07 và A06: kịch bản số 1 của OWASP (câu hỏi bảo mật để khôi phục tài khoản) là chuyện xác thực nhưng được xếp ở A06 vì cả cơ chế đã sai từ thiết kế — không có cách cài đặt nào làm câu hỏi bảo mật thành bằng chứng danh tính tốt. Chi tiết luồng khôi phục tài khoản: Bài 7.
2. Bài toán gốc — vì sao thiết kế lại thiếu control
1. Yêu cầu được viết theo happy path. User story điển hình: "Là sinh viên, tôi muốn nộp bài quiz để được chấm điểm". Story này mô tả việc người dùng trung thực làm. Nó không nói gì về sinh viên nộp hai lần, nộp sau hạn, nộp cho lần làm bài của người khác, hay sửa thời điểm bắt đầu. OWASP yêu cầu ngược lại: "determine the correct flow and failure states" — story phải có cả trạng thái lỗi. Khi spec im lặng, dev sẽ code đúng cái spec nói, và control không ai yêu cầu thì không ai viết.
2. Giao diện được coi là ranh giới. Nút "Nộp bài" bị ẩn sau giờ hạn, trường "giá" không cho sửa, bước 3 chỉ hiện khi xong bước 2 — tất cả đều là luật thực thi ở trình duyệt. Business Logic Cheat Sheet: "Fields that are 'not editable in the UI' are fully editable at the HTTP layer." ASVS 5.0 yêu cầu 2.2.2: validation phải ở "a trusted service layer"; validation phía client "must not be relied upon as a security control". CWE-602 (Client-Side Enforcement of Server-Side Security) nằm trong A06 chính vì lý do này: lỗi không ở dòng code JavaScript, mà ở quyết định đặt luật ở đó.
3. Đồng thời là thứ dev không thấy khi phát triển. Trên máy dev chỉ có một người bấm, một request một lúc. Đoạn code "đọc số chỗ còn lại, nếu > 0 thì ghi danh và trừ 1" chạy đúng 100% trong mọi test tuần tự. Nó chỉ hỏng khi hai request chạy xen kẽ — và theo Business Logic Cheat Sheet, khoảng thời gian "vài micro giây" không phải là bảo vệ: "If the window exists at all, assume it's exploitable." Với LMS có cao điểm cuối hạn (CV: ~400 request/giây ở LMS Uni), xen kẽ còn xảy ra với chính người dùng trung thực, không cần ai cố ý.
4. Không ai đánh giá giá trị của tính năng. Tính năng nào phát ra giá trị — điểm, chứng chỉ, chỗ học, mã giảm giá, email gửi đi nhân danh hệ thống — là mục tiêu của người muốn lợi dụng. Business Logic Cheat Sheet gọi đó là "abuse magnets" và yêu cầu control riêng cho chúng ngoài xác thực. Thiếu bước business risk profiling (mục 1.1 đoạn 3), mọi tính năng được thiết kế với cùng một mức cẩn trọng thấp.
5. "Shift-left" dừng ở code. Team đã có SAST, có review, nên tin rằng bảo mật đã "dịch sang trái". OWASP viết: "we need to move beyond 'shift-left' in the coding space, to pre-code activities such as requirements writing and application design". SAST đọc code; nó không đọc được control không có trong code.
6. Chi phí sửa tăng theo thời gian. Ví dụ minh hoạ bằng số (không phải số đo): phát hiện "hai sinh viên ghi danh được vào chỗ cuối cùng" ở buổi refinement tốn 15 phút bàn và một dòng trong acceptance criteria (tiêu chí chấp nhận). Phát hiện ở code review: sửa vài chục dòng, thêm một unique index. Phát hiện ở production sau khi lớp đã vượt sĩ số 40 người: phải thêm migration trên bảng đang chạy, dọn dữ liệu trùng, liên hệ từng sinh viên bị huỷ ghi danh, và giải thích với nhà trường. Chênh lệch không nằm ở dòng code, mà ở dữ liệu thật đã sai và con người bị ảnh hưởng.
7. Ví dụ có số của chính OWASP — kịch bản số 2. Một chuỗi rạp chiếu cho đặt vé nhóm có giảm giá, tối đa 15 người trước khi phải đặt cọc. Kẻ xấu "could threat model this flow" và thử đặt 600 ghế ở mọi rạp chỉ bằng vài request. Áp số minh hoạ: một suất chiếu 120 ghế, 5 suất mỗi tối; giữ 600 ghế không cọc trong giờ cao điểm là giữ toàn bộ ghế của một tối, doanh thu bằng 0 dù không ai "hack" gì — mọi request đều hợp lệ về cú pháp và quyền. Control còn thiếu là luật nghiệp vụ: trần số ghế trên mỗi tài khoản trên mỗi khoảng thời gian, và trần tổng ghế giữ chưa cọc. Không có trong spec thì không có trong code.
flowchart TD S["User story: chỉ mô tả<br/>người dùng trung thực"] --> C["Dev code đúng story"] C --> T["Test tuần tự, 1 người dùng<br/>— đều xanh"] T --> R["SAST / review:<br/>không có code sai để bắt"] R --> P["Production: song song,<br/>sai thứ tự, khối lượng lớn"] P --> X["Hỏng ở chỗ<br/>không có control nào"] S -. "chặn rẻ nhất ở đây:<br/>failure states + invariant" .-> FIX["Story có luồng lỗi,<br/>giới hạn, luật đồng thời"]
Cách nói khi phỏng vấn: "Lỗi thiết kế lọt qua mọi lớp kiểm tra vì mỗi lớp chỉ kiểm thứ được viết ra. Story chỉ tả người dùng trung thực, dev code đúng story, test chạy tuần tự nên xanh, công cụ quét không có dòng code sai để bắt. Đến production mới có song song, sai thứ tự và khối lượng lớn. Nên chỗ rẻ nhất để chặn là ngay trong story: viết thêm luồng lỗi, giới hạn và luật khi hai người cùng làm một việc."
3. Cơ chế — mỗi dạng sai ở giả định nào
Mỗi tiểu mục dưới đây theo cùng một khung: giả định ngầm trong thiết kế, vì sao giả định đó sai, hệ quả, và control còn thiếu. Ví dụ lấy bối cảnh LMS (minh hoạ), cộng ba kịch bản chính thức của OWASP.
3.1 Thiếu giới hạn nghiệp vụ — kịch bản số 2
1. Giả định sai: "mỗi request hợp lệ thì cả chuỗi request cũng hợp lệ". Validation kiểm từng request (số ghế ≤ 15) nhưng không ai kiểm tổng theo người, theo thời gian, theo toàn hệ thống.
2. Khung chuẩn: ASVS 5.0 có hẳn một nhóm cho việc này. 2.1.3 yêu cầu tài liệu phải định nghĩa "business logic limits and validations … both per-user and globally across the application"; 2.3.2 yêu cầu các giới hạn đó được cài theo tài liệu; 2.4.1 yêu cầu control chống tự động hoá cho các chức năng có thể dẫn tới "data exfiltration, garbage-data creation, quota exhaustion … or overuse of costly resources". ASVS minh hoạ: "continuous small funds transfers or adding a million friends one at a time" — mỗi hành động nhỏ, hợp lệ; hại nằm ở tổng.
3. Ba tầng giới hạn mà Business Logic Cheat Sheet gợi ý — "A per-action cap … plus a per-account cap … plus a per-source cap". Ví dụ LMS: tính năng "giảng viên gửi email mời học viên vào khoá": tối đa 200 địa chỉ mỗi lần gửi (per-action), tối đa 1.000 địa chỉ mỗi ngày mỗi giảng viên (per-account), tối đa 5.000 mỗi ngày mỗi trường (per-tenant, toàn cục). Số là minh hoạ; điều quan trọng là có ba con số và mỗi con số do ai quyết định (PO cùng tech lead, không phải dev tự đặt).
4. Vì sao rate limit ở gateway không thay được: rate limit theo IP ở Nginx đo số request; giới hạn nghiệp vụ đo đại lượng nghiệp vụ (số email, số ghế, số điểm thưởng). Một request mời 200 người và một request mời 1 người có cùng giá ở gateway. Thiết kế rate limit nhiều tầng đã có series riêng: Rate limit theo route, user, tenant.
flowchart TD
R["Request mời 200 email"] --> L1{"Per-action:<br/>≤ 200 / lần?"}
L1 -- "vượt" --> N1["422 — từ chối"]
L1 -- "ok" --> L2{"Per-account:<br/>hôm nay + 200 ≤ 1.000?"}
L2 -- "vượt" --> N2["429 / 422 — hết hạn mức"]
L2 -- "ok" --> L3{"Per-tenant:<br/>trường hôm nay ≤ 5.000?"}
L3 -- "vượt" --> N3["Từ chối + alert"]
L3 -- "ok" --> OK["Ghi hạn mức (atomic)<br/>rồi xếp job gửi"]
Cách nói khi phỏng vấn: "Rate limit ở gateway đếm số request, còn luật nghiệp vụ phải đếm đúng thứ có giá trị: số email gửi đi, số ghế giữ, số điểm thưởng. Em đặt ba trần: mỗi lần, mỗi tài khoản mỗi ngày, và toàn trường mỗi ngày, con số do PO chốt cùng em. Và phần tăng hạn mức phải làm nguyên tử, nếu không hai request song song sẽ cùng lọt qua trần."
3.2 Tin giá trị do client gửi — giá, điểm, vai trò, thời điểm
1. Giả định sai: "giá trị này client không sửa được vì giao diện không cho sửa". Business Logic Cheat Sheet đưa ra nguyên tắc mà nó gọi là "The single most effective defensive habit": "if a value in the request influences price, access, ownership, or state, assume the client made it up and recompute it from trusted data".
2. Ví dụ kinh điển trong cheat sheet: mọi input đều đúng định dạng (product ID tồn tại, số lượng là số nguyên dương) nhưng server tính tổng tiền từ giá client gửi lên — "no amount of input validation helps, because the input is syntactically perfect". Đây là điểm phân biệt A06 với A05: input đúng cú pháp, sai là ở chỗ server tin nó.
3. Bản LMS của cùng lỗi (minh hoạ): client quiz gửi lên score tự chấm ở trình duyệt, hoặc gửi
started_at để server tính thời gian làm bài, hoặc gửi attempt_no để server biết đây là lần làm thứ mấy. Cả ba đều là
"state quan trọng do bên ngoài kiểm soát" — CWE-642 External Control of Critical State Data, và CWE-472 External Control of Assumed-Immutable Web
Parameter.
4. Control còn thiếu: bảng của cheat sheet tóm gọn — client gửi định danh và ý định (ID sản phẩm, số lượng, mã giảm giá, câu trả lời), server tự tra hoặc tự tính mọi giá trị dẫn xuất (giá, giảm giá, phí, điểm, thời gian). Danh tính người thực hiện luôn lấy từ session/token phía server: "never from a field the user can edit". Cách lọc field đặc quyền khỏi request (mass assignment) đã có ở Bài 1 mục 4.3; Livewire có biến thể riêng — public property là client-writable.
flowchart TD C["Client gửi:<br/>attempt_id + câu trả lời"] --> V["Validate định dạng<br/>(FormRequest / DTO)"] V --> L["Server tra: attempt thuộc ai,<br/>started_at, đáp án, hạn"] L --> D["Server tự tính:<br/>điểm, thời gian làm, quá hạn?"] D --> W["Ghi kết quả"] C -. "score / started_at từ client:<br/>không bao giờ dùng" .-> V
Cách nói khi phỏng vấn: "Nguyên tắc của em là client chỉ được gửi ý định, không được gửi kết luận. Sinh viên gửi câu trả lời, server tự chấm; gửi mã giảm giá, server tự tính tiền; người đang thao tác là ai thì lấy từ session chứ không từ body. Validation định dạng không cứu được trường hợp này, vì giá trị gian lận vẫn đúng định dạng — cái sai là server đã tin nó."
3.3 Workflow không được thực thi — bỏ bước, lặp bước, đảo thứ tự
1. Giả định sai: "người dùng sẽ đi qua các màn hình theo thứ tự giao diện dẫn". Mỗi bước của luồng nhiều bước (đăng ký → xác minh email → ghi danh; bắt đầu làm bài → nộp → chấm → công bố điểm) lại là một endpoint riêng, gọi trực tiếp được. CWE-841 Improper Enforcement of Behavioral Workflow là CWE trung tâm của dạng này; ASVS 2.3.1 yêu cầu luồng nghiệp vụ được xử lý "in the expected sequential step order and without skipping steps".
2. Biến thể hay gặp nhất: lưu "đang ở bước mấy" trong hidden field hay cookie client đọc/ghi được. Cheat sheet: "This is not enforcement. The client can set it to whatever value it wants. State lives on the server."
3. Ba lỗi con của cùng một giả định, áp vào vòng đời một lần làm quiz (minh hoạ):
- Bỏ bước: gọi thẳng endpoint "công bố điểm" khi bài còn ở trạng thái "đã nộp, chưa chấm".
- Lặp bước: gọi "nộp bài" lần thứ hai sau khi đã nộp — ghi đè câu trả lời sau khi đáp án đã công bố. Cheat sheet: "Reject Replays of Completed Steps".
- Treo trạng thái dở dang: lần làm bài bắt đầu từ tuần trước vẫn "đang làm" và vẫn nộp được. Cheat sheet: "Expire Partial States" — trạng thái chờ phải có hạn.
4. Control còn thiếu: một state machine (máy trạng thái) tường minh lưu ở server: tập trạng thái hữu hạn, bảng chuyển trạng thái được phép, và mỗi endpoint chỉ chạy khi trạng thái hiện tại đúng bằng trạng thái nguồn của chuyển đó. Hai chi tiết thường bị bỏ sót: (a) kiểm trạng thái và đổi trạng thái phải là một thao tác nguyên tử, nếu không hai request "nộp" song song cùng thấy "đang làm" (mục 3.4); (b) các điều kiện thời gian (hạn nộp, hạn trạng thái chờ) tính theo một đồng hồ tin cậy — với 5 app node (CV), đồng hồ của database là lựa chọn an toàn hơn đồng hồ từng node.
stateDiagram-v2 [*] --> in_progress: bắt đầu (server ghi started_at, deadline_at) in_progress --> submitted: nộp (chỉ khi now ≤ deadline_at) in_progress --> abandoned: quá hạn + ân hạn (job dọn) submitted --> graded: chấm (worker / giảng viên) graded --> published: công bố điểm published --> [*] abandoned --> [*]
Cách nói khi phỏng vấn: "Với luồng nhiều bước em không tin thứ tự màn hình. Mỗi luồng có một máy trạng thái lưu ở server: danh sách trạng thái, các bước chuyển được phép, và endpoint nào cũng chỉ chạy khi trạng thái hiện tại đúng là trạng thái nguồn của bước đó. Bước đã xong thì gọi lại là bị từ chối, trạng thái dở dang có hạn. Và phần kiểm trạng thái với phần đổi trạng thái gộp trong một câu ghi có điều kiện, để hai request cùng lúc không cùng lọt."
3.4 Race condition — kiểm tra rồi mới hành động
1. Giả định sai: "từ lúc đọc tới lúc ghi, không có gì thay đổi". Business Logic Cheat Sheet định nghĩa dạng này theo hình dạng của thao tác: "Any operation that reads a value, makes a decision, and then writes a value is a potential race condition." Tên kỹ thuật: TOCTOU (time-of-check to time-of-use — thời điểm kiểm khác thời điểm dùng). CWE-362 vào hẳn A06 năm 2025; ASVS 15.4.2 yêu cầu kiểm trạng thái và hành động phụ thuộc vào nó "are performed as a single atomic operation"; ASVS 2.3.4 yêu cầu khoá ở mức nghiệp vụ để tài nguyên số lượng có hạn "(such as theater seats or delivery slots) cannot be double-booked".
2. Ví dụ có số (minh hoạ): lớp học phần còn đúng 1 chỗ (seats_left = 1). Hai sinh viên bấm "Ghi danh" cách
nhau 3 ms, rơi vào hai app node khác nhau.
- Request A đọc
seats_left→ 1. Request B đọcseats_left→ 1 (A chưa ghi). - A kết luận "còn chỗ", tạo bản ghi ghi danh, ghi
seats_left = 1 − 1 = 0. - B cũng đã kết luận "còn chỗ" từ bước 1, tạo bản ghi ghi danh, ghi
seats_left = 1 − 1 = 0(tính từ giá trị đã đọc), hoặc0 − 1 = −1nếu ghi kiểu trừ tại chỗ. - Kết quả: 2 sinh viên cho 1 chỗ. Nếu B ghi "0" thì còn tệ hơn: số chỗ trông đúng, nhưng sĩ số thực tế là 41/40 — lỗi bị giấu trong dữ liệu, chỉ lộ ra khi ai đó đếm lại.
3. Danh sách thao tác có hình dạng nguy hiểm (diễn giải từ cheat sheet, đổi sang bối cảnh LMS): kiểm số dư rồi trừ; kiểm mã chưa dùng rồi đánh dấu đã dùng; kiểm "chưa quá N lớp" rồi thêm một lớp; kiểm suất thi còn trống rồi đặt; kiểm "chưa nộp" rồi ghi bài nộp; kiểm email chưa tồn tại rồi tạo tài khoản. Mỗi cái là một race condition "unless the check and the act are inside a single atomic operation".
4. Vì sao đây là lỗi thiết kế chứ không chỉ lỗi code: cheat sheet nói rõ "At the default isolation level of most
databases, the check and the update run separately and another transaction can slip between them" — tức là bọc trong
DB::transaction thôi chưa đủ. Phải chọn một trong ba cơ chế (mục 4.4): câu UPDATE có điều kiện và đếm số dòng bị ảnh hưởng;
khoá dòng (SELECT … FOR UPDATE); hoặc isolation SERIALIZABLE kèm retry. Và chốt chặn cuối là ràng buộc ở database
(unique constraint) — chọn cái nào là quyết định thiết kế phải ghi vào spec, vì nó quyết định cả schema.
5. Ngộ nhận "nhanh lắm, không ai chen kịp". Cheat sheet bác thẳng: "A common rationalization is 'the window between the check and the update is microseconds' … If the window exists at all, assume it's exploitable." Ở cao điểm LMS, chuyện hai request rơi vào cùng vài mili giây xảy ra tự nhiên — với cả double-click của người trung thực (đã có lab ở Chống double-submit, mục 0.1).
sequenceDiagram participant A as Request A (node 1) participant B as Request B (node 2) participant DB as Database A->>DB: đọc seats_left DB-->>A: 1 B->>DB: đọc seats_left DB-->>B: 1 A->>DB: ghi danh A, seats_left = 0 B->>DB: ghi danh B, seats_left = 0 Note over DB: 2 người cho 1 chỗ — số chỗ trông vẫn đúng
Cách nói khi phỏng vấn: "Em nhận diện race condition bằng hình dạng: cứ thao tác nào đọc một giá trị, quyết định, rồi ghi, là có cửa sổ để request khác chen vào. Bọc trong transaction ở mức isolation mặc định chưa đủ. Phải gộp kiểm và ghi thành một bước nguyên tử — một câu cập nhật có điều kiện rồi xem có bao nhiêu dòng bị ảnh hưởng, hoặc khoá dòng — và để database giữ chốt cuối bằng ràng buộc unique. Đừng lập luận là cửa sổ chỉ vài micro giây, vì lúc cao điểm chính người dùng thật đã tự tạo ra request song song."
3.5 Bằng chứng danh tính yếu ngay từ thiết kế — kịch bản số 1
1. OWASP: luồng khôi phục tài khoản dùng "questions and answers" bị "prohibited by NIST 800-63b, the OWASP ASVS, and the OWASP Top 10", vì "Questions and answers cannot be trusted as evidence of identity, as more than one person can know the answers". Kết luận của OWASP không phải "cài cho tốt hơn" mà là "Such functionality should be removed and replaced with a more secure design" — đúng định nghĩa của A06: không cách cài nào cứu được.
2. Bài học tổng quát hơn kịch bản: mỗi khi thiết kế chọn một bằng chứng (câu trả lời, mã sinh viên, ngày sinh, địa chỉ email), hỏi "ngoài chủ tài khoản còn ai biết hoặc đoán được không". Trong LMS, mã sinh viên in trên thẻ, trên danh sách lớp, trên bảng điểm công khai — dùng mã sinh viên + ngày sinh làm cách "xác minh" để đặt lại mật khẩu là cùng lỗi với câu hỏi bảo mật. Luồng đúng (link một lần qua kênh đã xác minh, hạn ngắn, MFA) thuộc Bài 7.
flowchart TD
E["Thiết kế chọn bằng chứng danh tính"] --> Q{"Ngoài chủ tài khoản,<br/>còn ai biết / tra được?"}
Q -- "Có (câu hỏi bảo mật,<br/>mã SV + ngày sinh)" --> BAD["Bỏ thiết kế,<br/>không vá được"]
Q -- "Không (thứ đang sở hữu:<br/>email/điện thoại đã xác minh, authenticator)" --> OK["Dùng được — chi tiết ở Bài 7"]
Cách nói khi phỏng vấn: "Câu hỏi bảo mật là ví dụ OWASP chọn để minh hoạ lỗi thiết kế, vì không có cách cài nào làm nó an toàn: nhiều người khác có thể biết câu trả lời. Em áp câu hỏi đó cho mọi bằng chứng danh tính: ngoài chủ tài khoản còn ai biết hoặc tra được không. Ở LMS, mã sinh viên với ngày sinh là cùng loại lỗi, vì chúng nằm trên danh sách lớp. Cách sửa là đổi thiết kế, không phải thêm validation."
3.6 Trust boundary violation — trộn dữ liệu tin cậy và không tin cậy
1. CWE-501 Trust Boundary Violation, một trong năm CWE OWASP nêu tên ở phần Background: hệ thống đặt dữ liệu đã kiểm và dữ liệu
chưa kiểm vào cùng một cấu trúc (session, một object, một bảng), sau đó code khác không còn phân biệt được và đối xử với tất cả như đã
tin. Ví dụ minh hoạ: bước 1 của luồng lưu student_code từ form vào session "để dùng sau"; bước 3 đọc session và hiểu đó là "mã sinh
viên đã xác minh" — trong khi chưa ai xác minh.
2. Trust boundary (ranh giới tin cậy) là đường mà dữ liệu đi qua thì mức tin cậy đổi: trình duyệt → Nginx, Nginx → app, app → database, service này → service kia. Threat modeling bắt đầu bằng việc vẽ các đường này (mục 5.2) vì phần lớn control phải đặt đúng tại đó. OWASP Threat Modeling Cheat Sheet: mô hình phải cho thấy "trust boundaries, data flows, data stores, processes, and the external entities".
3. Biến thể kiến trúc — quan trọng với CV (mục 7.1): hai ứng dụng dùng chung một database. Mỗi ứng dụng có bộ luật nghiệp vụ riêng (Moodle kiểm quyền theo capability, ghi event, tính lại điểm tổng); nếu ứng dụng thứ hai ghi thẳng vào bảng của ứng dụng thứ nhất, nó đi vòng qua toàn bộ các luật đó. Database không phải ranh giới tin cậy — nó chỉ lưu những gì được ghi vào.
flowchart TD
U["Dữ liệu từ request<br/>(không tin cậy)"] --> V{"Đã qua validate +<br/>kiểm nghiệp vụ chưa?"}
V -- "Chưa" --> RAW["Giữ riêng, đặt tên rõ<br/>(pending_*, input_*)"]
V -- "Rồi" --> T["Object có kiểu, đã tin<br/>(verified_*)"]
T --> S["Session / bảng / service khác"]
RAW -. "không bao giờ đi thẳng" .-> S
Cách nói khi phỏng vấn: "Trust boundary là chỗ dữ liệu đổi mức tin cậy, như từ trình duyệt vào app hay từ app này sang app kia. Lỗi xảy ra khi dữ liệu chưa kiểm và dữ liệu đã kiểm bị trộn chung một chỗ, như cùng nằm trong session, rồi đoạn code sau coi tất cả là đã kiểm. Em tách bằng kiểu dữ liệu và bằng tên: chỉ code xác minh mới được tạo ra giá trị đã tin. Ở mức kiến trúc cũng vậy: hai ứng dụng chung database thì database không phải ranh giới, ứng dụng nào ghi cũng phải tuân luật của chủ dữ liệu."
3.7 Tính năng phát ra giá trị và bot — kịch bản số 3
1. OWASP: một chuỗi bán lẻ không có "protection against bots run by scalpers" mua card đồ hoạ để bán lại; giải pháp OWASP gợi ý là "Careful anti-bot design and domain logic rules, such as purchases made within a few seconds of availability" để nhận diện giao dịch không thật. Ý chính: phòng thủ là luật nghiệp vụ (mua trong vài giây sau khi mở bán là bất thường), không chỉ là CAPTCHA.
2. ASVS 2.4.2 (level 3) có yêu cầu tương ứng: luồng nghiệp vụ "require realistic human timing". Business Logic Cheat Sheet liệt kê các dạng lợi dụng cần thiết kế để chống: multi-accounting (một người nhiều tài khoản), self-referral, coupon stacking, reset dùng thử miễn phí, resource exhaustion qua tính năng gửi email/webhook, và enumeration qua thông báo khác nhau. Control đi kèm: rate limit theo tính năng, tín hiệu danh tính mạnh hơn email, audit trail cho mọi thao tác phát giá trị, trần ở mọi tầng, và "Asymmetric consequences" — hành động nhận giá trị nên khó hơn hành động thường.
3. Bản LMS (minh hoạ): mở đăng ký lớp học phần lúc 8:00 cho 100.000 sinh viên; lớp "đẹp" hết chỗ trong vài giây. Nếu có công cụ tự động giữ chỗ hộ rồi "nhượng lại", đó là đúng kịch bản số 3. Control thiết kế: giới hạn số lớp giữ mỗi sinh viên, không cho chuyển nhượng chỗ, hàng đợi công bằng (xếp hàng theo thời điểm vào, mở theo đợt) thay vì ai nhanh nhất thắng, và log để phát hiện tài khoản ghi danh nhanh hơn người thật.
flowchart TD
F["Tính năng mới"] --> G{"Có phát ra giá trị?<br/>(chỗ học, điểm, mã, email, credit)"}
G -- "Không" --> N["Control chuẩn: auth + validate"]
G -- "Có" --> C1["Trần mỗi lần / mỗi tài khoản / toàn cục"]
C1 --> C2["Rate limit riêng cho tính năng"]
C2 --> C3["Audit log: ai, cho ai, bao nhiêu, lúc nào"]
C3 --> C4["Alert theo tốc độ bất thường"]
Cách nói khi phỏng vấn: "Em hỏi mỗi tính năng mới một câu: nó có phát ra thứ có giá trị không, như chỗ học, điểm, mã giảm giá hay email gửi nhân danh hệ thống. Nếu có, nó cần thêm bộ control riêng: trần theo lần, theo tài khoản và toàn cục, rate limit riêng cho tính năng đó, audit log, và cảnh báo khi tốc độ bất thường. Kịch bản bot mua card của OWASP cho thấy phòng thủ chính là luật nghiệp vụ, như mua trong vài giây sau khi mở bán thì đáng ngờ, chứ không chỉ là CAPTCHA."
3.8 Thiếu phân tách — tenant, tầng, bề mặt tấn công
1. Hai dòng cuối của "How to prevent": "Segregate tier layers on the system and network layers, depending on the exposure and protection needs" và "Segregate tenants robustly by design throughout all tiers". CWE tương ứng: CWE-653 Insufficient Compartmentalization, CWE-1125 Excessive Attack Surface.
2. Giả định sai là "lớp phân quyền đã lo hết". Phân quyền chặn request sai; phân tách chặn hệ quả lan rộng khi một lớp đã hỏng — một lỗi ở module dùng chung không được trở thành lỗi đọc dữ liệu của cả 4 trường. Đó là lý do OWASP viết tách tenant "beyond those needed for access control". Cách tách tenant (cột tenant_id + global scope, RLS, schema riêng, database riêng) và trade-off từng cách đã có ở Bài 1 mục 5.3 và ABAC (PostgreSQL RLS).
3. Excessive attack surface trong thực tế thường là tính năng không ai dùng nhưng vẫn mở: endpoint import cũ, trang admin của plugin đã thôi dùng, API phiên bản cũ còn chạy song song. ASVS 15.2.3: production không được chứa "extraneous functionality such as test code, sample snippets, and development functionality". Response Eliminate (bỏ hẳn tính năng) trong threat modeling (mục 5.2) là control rẻ nhất cho loại này.
flowchart TD
B["Một lỗi lọt qua phân quyền"] --> Q{"Có phân tách không?"}
Q -- "Không: chung bảng, chung user DB,<br/>chung network" --> W["Lộ dữ liệu cả 4 trường"]
Q -- "Có: RLS theo tenant, user DB riêng,<br/>tầng data không ra Internet" --> S["Thiệt hại dừng ở 1 tenant / 1 tầng"]
Cách nói khi phỏng vấn: "Phân quyền chặn request sai, còn phân tách giới hạn thiệt hại khi phân quyền đã hỏng. OWASP yêu cầu tách tenant vượt ra ngoài phần phân quyền chính vì lý do đó. Với hệ nhiều trường chung một codebase, em muốn một lỗi ở module dùng chung không thể đọc chéo dữ liệu trường khác, nên có thêm một lớp ở database như row-level security. Và tính năng không còn ai dùng thì bỏ hẳn, vì đó là cách giảm bề mặt tấn công rẻ nhất."
3.9 Hình động: hai request tranh chỗ cuối cùng
Hình dưới gom cơ chế phòng thủ quan trọng nhất của bài: biến "kiểm tra rồi ghi" (hai bước, có cửa sổ) thành "một câu ghi có điều kiện" (một bước, database tự xếp hàng). Hàng trên: cả hai request đọc thấy còn 1 chỗ và cùng ghi. Hàng dưới: mỗi request gửi một câu cập nhật kèm điều kiện "còn chỗ"; database thực hiện lần lượt trên cùng một dòng, request đến sau thấy điều kiện không còn đúng, cập nhật 0 dòng, và ứng dụng từ chối.
Cách nói khi phỏng vấn: "Em hình dung hai hàng. Hàng trên là kiểu đọc rồi mới ghi: hai request cùng đọc thấy còn một chỗ và cùng ghi danh, không ai báo lỗi, sĩ số lố mà số chỗ trông vẫn đúng. Hàng dưới là gộp thành một câu cập nhật có điều kiện còn chỗ: database xếp hàng hai câu trên cùng một dòng, câu sau thấy không còn chỗ nên không cập nhật dòng nào. Việc của ứng dụng là đọc số dòng bị ảnh hưởng, bằng không thì báo lớp đã đủ, chứ không được im lặng coi như thành công."
4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)
Mỗi tiểu mục có: mẫu sai (ngắn, để nhận ra khi review), mẫu đúng (giải thích kỹ), một sơ đồ cho luồng phòng thủ. API Laravel đối chiếu với docs
Laravel 13 (Database, Query Builder, Cache — atomic locks, Rate Limiting) và NestJS với docs NestJS (Validation, Rate limiting) cùng docs TypeORM
(Transactions, Select Query Builder — lock modes, UpdateResult), tra qua Context7 ngày 29/10/2026. Bảng, cột, giá trị là minh hoạ, lấy
bối cảnh một LMS. Code mẫu chưa được chạy (series doc-only).
4.1 Server tự tính mọi giá trị dẫn xuất — nộp bài quiz
// ===== SAI (Laravel) — tin điểm và thời điểm do client gửi =====
public function submit(Request $request, Attempt $attempt)
{
$attempt->update([
'score' => $request->input('score'), // trình duyệt tự chấm
'started_at' => $request->input('started_at'), // client nói mình bắt đầu lúc nào
'submitted_at' => now(),
]);
}
// ===== SAI (NestJS) =====
@Post(':id/submit')
submit(@Param('id') id: string, @Body() body: any) { // any: nhận mọi field
return this.attempts.update(id, { score: body.score, userId: body.userId });
}
// ===== ĐÚNG (Laravel) =====
// FormRequest: chỉ nhận câu trả lời, đúng hình dạng
public function rules(): array
{
return [
'answers' => ['required', 'array', 'max:200'],
'answers.*.question_id' => ['required', 'integer'],
'answers.*.choice_id' => ['nullable', 'integer'],
];
}
// Service: mọi giá trị dẫn xuất lấy từ dữ liệu tin cậy
public function submit(User $user, int $attemptId, array $answers): Attempt
{
$attempt = Attempt::whereKey($attemptId)
->where('user_id', $user->id) // danh tính từ session, không từ body
->firstOrFail();
$key = $this->answerKeys->forQuiz($attempt->quiz_id); // đáp án nằm ở server
$score = $this->grader->grade($key, $answers); // server chấm
// thời gian làm = submitted_at − started_at, cả hai do server ghi
return $this->transitions->submit($attempt, $answers, $score); // mục 4.3
}
// ===== ĐÚNG (NestJS) =====
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
// submit-attempt.dto.ts
class AnswerDto {
@IsInt() questionId: number;
@IsOptional() @IsInt() choiceId?: number;
}
export class SubmitAttemptDto {
@IsArray() @ArrayMaxSize(200) @ValidateNested({ each: true }) @Type(() => AnswerDto)
answers: AnswerDto[];
}
// controller
@Post(':id/submit')
submit(@Param('id', ParseIntPipe) id: number, @Body() dto: SubmitAttemptDto, @Req() req) {
return this.attempts.submit(req.user.id, id, dto.answers); // userId từ token đã xác thực
}
Giải thích mẫu đúng, từng điểm:
- Client gửi ý định, server tính kết luận. Request chỉ chứa câu trả lời. Điểm do
gradertính từ đáp án lưu ở server;started_atđược server ghi lúc bắt đầu lần làm (mục 4.3),submitted_atdo server ghi lúc nộp — không còn giá trị thời gian nào đến từ client. Đây là nguyên tắc "recompute it from trusted data" của Business Logic Cheat Sheet áp vào LMS. - Danh tính người nộp lấy từ phiên đăng nhập, và được dùng ngay trong điều kiện truy vấn (
where('user_id', …)) — vừa là kiểm quyền theo object (Bài 1 mục 4.1), vừa đảm bảo body không thể nói "tôi là người khác". - Whitelist ở tầng nhận dữ liệu. Với NestJS, docs ghi
whitelist: trueloại khỏi object mọi property "that do not use any validation decorators"; thêmforbidNonWhitelisted: truethì thay vì lặng lẽ loại, validator "will throw an exception". Em chọn ném lỗi cho API nội bộ của mình — client gửiscorelà dấu hiệu bug hoặc dò, nên muốn thấy nó trong log; với API công khai cho đối tác có thể chọn chỉ loại bỏ để tương thích ngược. Với Laravel, dùng$request->validated()(chỉ trả các key có rule) thay vì$request->all(). - Giới hạn kích thước ngay từ validation (
max:200,@ArrayMaxSize(200)): đó là business limit nhỏ nhất — quiz không có quá 200 câu — và chặn luôn request khổng lồ làm grader chạy lâu (ASVS 15.1.3 yêu cầu tài liệu hoá chức năng tốn tài nguyên). - Validation không thay được bước tính lại. Có người thêm rule
'score' => 'integer|between:0,10'và nghĩ là đã an toàn: rule đó chỉ đảm bảo điểm gian lận đúng định dạng. Field không được tồn tại trong input mới là đúng.
flowchart TD
R["Request nộp bài"] --> W{"Field ngoài whitelist?"}
W -- "Có (score, userId...)" --> E["400 + log — dấu hiệu bug / dò"]
W -- "Không" --> O["Tra attempt theo id + user từ session"]
O --> G["Server chấm từ đáp án lưu ở server"]
G --> T["Server ghi submitted_at<br/>(đồng hồ tin cậy)"]
T --> S["Chuyển trạng thái (mục 4.3)"]
Cách nói khi phỏng vấn: "Ở luồng nộp bài, request chỉ chứa câu trả lời. Điểm do server chấm từ đáp án lưu ở server, thời điểm bắt đầu và nộp do server ghi, người nộp lấy từ phiên đăng nhập và nằm ngay trong điều kiện truy vấn. Tầng nhận dữ liệu bật whitelist: field nào không khai báo thì bị từ chối, nên client gửi kèm điểm là thấy ngay trong log. Thêm rule kiểm định dạng cho điểm là sai hướng, vì field đó vốn không được phép tồn tại."
4.2 Giới hạn nghiệp vụ: trần theo lần, theo tài khoản, theo tenant
// ===== SAI — chỉ có validation từng request, không có trần tổng =====
'emails' => ['required', 'array'], // không giới hạn số phần tử
'emails.*' => ['email'],
// ... và gửi ngay, không đếm hạn mức trong ngày
// ===== ĐÚNG (Laravel) =====
// AppServiceProvider::boot — rate limit RIÊNG cho tính năng, theo user
RateLimiter::for('invitations', function (Request $request) {
return Limit::perHour(20)->by($request->user()->id);
});
// routes/web.php
Route::post('/courses/{course}/invitations', [InvitationController::class, 'store'])
->middleware(['auth', 'throttle:invitations']);
// FormRequest — per-action cap
'emails' => ['required', 'array', 'max:200'],
'emails.*' => ['email:rfc', 'distinct'],
// Service — per-account và per-tenant cap, tăng hạn mức NGUYÊN TỬ
public function invite(User $teacher, Course $course, array $emails): void
{
$n = count($emails);
DB::transaction(function () use ($teacher, $n, $course, $emails) {
$ok = DB::table('invite_quotas')
->where('user_id', $teacher->id)->where('day', today())
->where('used', '<=', 1000 - $n) // trần mỗi giảng viên mỗi ngày
->increment('used', $n); // 1 câu UPDATE có điều kiện
$okTenant = DB::table('tenant_quotas')
->where('tenant_id', $course->tenant_id)->where('day', today())
->where('used', '<=', 5000 - $n)
->increment('used', $n);
if ($ok === 0 || $okTenant === 0) {
throw new QuotaExceeded(); // rollback cả hai lần tăng
}
InvitationBatch::createFor($course, $emails); // job gửi đọc batch sau commit
});
}
// ===== ĐÚNG (NestJS) =====
// app.module.ts — một throttler set riêng, tracker theo user thay vì IP
ThrottlerModule.forRoot([
{ name: 'default', ttl: minutes(1), limit: 100 },
{ name: 'invitations', ttl: minutes(60), limit: 20,
skipIf: (ctx) => !ctx.switchToHttp().getRequest().url.includes('/invitations'),
getTracker: (req) => String(req.user?.id ?? req.ip) },
]);
// DTO — per-action cap
export class InviteDto {
@IsArray() @ArrayMaxSize(200) @IsEmail({}, { each: true }) @ArrayUnique()
emails: string[];
}
// Service — tăng hạn mức bằng UPDATE có điều kiện, kiểm affected
await this.ds.transaction(async (m) => {
const r = await m.createQueryBuilder().update(InviteQuota)
.set({ used: () => 'used + :n' })
.where('user_id = :uid AND day = CURRENT_DATE AND used <= :cap', { uid, cap: 1000 - n })
.setParameter('n', n)
.execute();
if (!r.affected) throw new UnprocessableEntityException('Vượt hạn mức gửi trong ngày');
// ... tương tự cho tenant_quotas, rồi tạo batch
});
Giải thích mẫu đúng, từng điểm:
- Ba tầng, ba công cụ. Per-action ở validation (rẻ, chặn sớm); per-account và per-tenant ở database (chính xác, nguyên tử, sống qua restart, chung cho cả 5 app node); rate limit theo user ở middleware (chặn tần suất, không chặn khối lượng). Mỗi công cụ chặn một thứ khác nhau — bảng ở mục 5.1.
- Tăng hạn mức là check-then-act nên phải nguyên tử. Câu
where('used', '<=', 1000 - $n)->increment('used', $n)là một câu UPDATE có điều kiện: nếu hai request mỗi cái 600 email chạy song song khiused = 0, request đến sau thấyused = 600, điều kiện600 ≤ 400sai, 0 dòng bị ảnh hưởng. Viết kiểu "đọcused, so sánh trong PHP, rồi ghi" sẽ cho cả hai qua và tổng thành 1.200. Docs Laravel ghiupdatetrả về số dòng bị ảnh hưởng;incrementtrả về số dòng là theo mã nguồn query builder (gọi xuốngupdate) — chưa verify riêng ở bài này. Dòng quota của ngày phải được tạo trước (job đầu ngày hoặc upsert) — nếu chưa có dòng thì câu UPDATE cũng trả 0, phải phân biệt hai trường hợp. - Rollback có chủ đích. Hai lần tăng (giảng viên, trường) nằm trong một transaction; lần thứ hai vượt trần thì ném exception, docs Laravel: "If an exception is thrown … the transaction is automatically rolled back" — lần tăng thứ nhất cũng được hoàn lại. Không có transaction, giảng viên bị trừ hạn mức cho một lần gửi không xảy ra.
- Rate limit theo user, không theo IP. Cả trường có thể ra Internet qua cùng một IP NAT; limit theo IP sẽ chặn nhầm cả khoa.
NestJS: docs ghi
@Throttle()và các throttler set cógetTrackerđể "override the default tracker logic"; ví dụ trong docs dùng chínhskipIf+getTrackercho một set riêng. Cấu hìnhskipIftheo URL ở trên là minh hoạ — thực tế nên dùng@Throttle({ invitations: … })trên đúng handler và@SkipThrottlechỗ khác; cách đặt chính xác giữa nhiều set chưa verify. Lý thuyết chọn key và thuật toán: Phạm vi rate limit, 6 thuật toán. - Ai quyết định con số. 200 / 1.000 / 5.000 là minh hoạ. ASVS 2.1.3 yêu cầu các giới hạn được tài liệu hoá; trong team nghĩa là PO chốt cùng tech lead và ghi vào spec, có đường để trường xin nâng hạn mức — nếu không, con số sẽ bị dev đổi tuỳ ý khi có người kêu.
flowchart TD
R["POST /invitations (200 email)"] --> T{"Rate limit theo user:<br/>≤ 20 lần / giờ?"}
T -- "vượt" --> E1["429 + Retry-After"]
T -- "ok" --> V{"Validation:<br/>≤ 200 email, không trùng?"}
V -- "sai" --> E2["422"]
V -- "ok" --> Q["Transaction: UPDATE quota user<br/>+ UPDATE quota tenant có điều kiện"]
Q --> A{"Cả hai affected = 1?"}
A -- "không" --> RB["Rollback + 422 hết hạn mức"]
A -- "có" --> OK["Commit → job gửi đọc batch"]
Cách nói khi phỏng vấn: "Em dùng ba công cụ cho ba loại trần. Số email mỗi lần gửi thì chặn ở validation. Hạn mức mỗi giảng viên và mỗi trường trong ngày thì đếm trong database bằng một câu cập nhật có điều kiện, vì nó phải chính xác và chung cho mọi app node. Còn tần suất bấm thì có rate limit riêng cho tính năng, tính theo user chứ không theo IP vì cả trường có thể đi chung một IP. Hai lần tăng hạn mức nằm chung transaction, lần sau vượt thì hoàn lại cả lần trước."
4.3 Workflow là state machine ở server — kiểm và chuyển trong một câu
// ===== SAI — endpoint không kiểm trạng thái nguồn; trạng thái do client giữ =====
Attempt::whereKey($id)->update(['state' => 'submitted']); // nộp lại được, nộp sau hạn được
// <input type="hidden" name="step" value="3"> // "đang ở bước 3" do client khai
// ===== ĐÚNG (Laravel) =====
enum AttemptState: string
{
case InProgress = 'in_progress';
case Submitted = 'submitted';
case Graded = 'graded';
case Published = 'published';
case Abandoned = 'abandoned';
/** Bảng chuyển trạng thái hợp lệ — một nơi duy nhất, test được */
public function next(): array
{
return match ($this) {
self::InProgress => [self::Submitted, self::Abandoned],
self::Submitted => [self::Graded],
self::Graded => [self::Published],
self::Published, self::Abandoned => [],
};
}
}
final class AttemptTransitions
{
public function submit(Attempt $a, array $answers, float $score): Attempt
{
return DB::transaction(function () use ($a, $answers, $score) {
$affected = Attempt::query()
->whereKey($a->id)
->where('state', AttemptState::InProgress->value) // trạng thái nguồn phải đúng
->whereRaw('deadline_at >= NOW()') // hạn theo đồng hồ của DB
->update([
'state' => AttemptState::Submitted->value,
'score' => $score,
'submitted_at' => DB::raw('NOW()'),
]);
if ($affected === 0) {
throw new AttemptNotSubmittable($a->id); // đã nộp, quá hạn, hoặc không tồn tại
}
$a->answers()->createMany($answers); // cùng transaction với chuyển trạng thái
return $a->refresh();
});
}
}
// ===== ĐÚNG (NestJS + TypeORM) =====
const NEXT: Record<AttemptState, AttemptState[]> = {
in_progress: ['submitted', 'abandoned'],
submitted: ['graded'],
graded: ['published'],
published: [],
abandoned: [],
};
async submit(userId: number, id: number, answers: AnswerDto[], score: number) {
return this.ds.transaction(async (m) => {
const r = await m.createQueryBuilder().update(Attempt)
.set({ state: 'submitted', score, submittedAt: () => 'NOW()' })
.where('id = :id AND user_id = :userId AND state = :from AND deadline_at >= NOW()',
{ id, userId, from: 'in_progress' })
.execute();
if (!r.affected) throw new ConflictException('Bài làm không ở trạng thái cho phép nộp');
await m.insert(AttemptAnswer, answers.map((x) => ({ attemptId: id, ...x })));
});
}
Giải thích mẫu đúng, từng điểm:
- Bảng chuyển trạng thái ở một chỗ.
next()/NEXTlà "spec dạng code": PO đọc được, test đọc được (mục 5.6 — một test cho mỗi cặp trạng thái không hợp lệ). Mỗi phương thức chuyển (submit,grade,publish) dùng trạng thái nguồn lấy từ bảng này; không endpoint nào được tự setstate. - Kiểm và chuyển trong một câu UPDATE. Điều kiện
state = in_progressnằm trongWHEREcủa chính câu đổi trạng thái — đúng dạng "conditional update" mà Business Logic Cheat Sheet liệt kê: "succeeds only if the predicate still holds at write time. Check the number of affected rows. If zero, the check failed". Hai request "nộp" song song: câu thứ hai thấy trạng thái đã làsubmitted→ 0 dòng → 409/422. Lặp bước (nộp lại) bị chặn bằng chính điều kiện này, không cần code riêng. - Hạn nộp theo một đồng hồ.
NOW()của database thay chonow()của PHP/Node: với 5 app node, đồng hồ từng node có thể lệch nhau vài trăm mili giây đến vài giây nếu NTP có vấn đề; database là một nguồn duy nhất. Trade-off: test khó giả lập thời gian hơn (không dùng đượcCarbon::setTestNowcho điều kiện SQL) — có thể truyền thời điểm từ app nếu team đảm bảo đồng bộ giờ và chấp nhận lệch nhỏ. Ân hạn (grace period) nếu có thì là một cột riêng do giảng viên đặt, cộng vàodeadline_atở server. - Dữ liệu đi kèm cùng transaction với chuyển trạng thái. Câu trả lời được ghi sau khi chuyển thành công, cùng transaction: hoặc có cả trạng thái "đã nộp" lẫn câu trả lời, hoặc không có gì — ASVS 2.3.3: "either a business logic operation succeeds in its entirety or it is rolled back".
- Trạng thái dở dang có hạn.
Abandoneddo một job định kỳ chuyển (cũng bằng UPDATE có điều kiệnstate = in_progress AND deadline_at + ân hạn < NOW()) — đúng khuyến nghị "Expire Partial States". affectedcủa TypeORM:UpdateResult.affectedđược chú thích "Not all drivers support this"; với PostgreSQL nó lấy từrowCount(theo mã nguồn qua Context7). Với MySQL, "affected rows" mặc định đếm dòng thực sự đổi giá trị, không phải dòng khớpWHERE— ở đây luôn đổistatenên không ảnh hưởng, nhưng là bẫy khi câu UPDATE có thể ghi giá trị trùng (chưa verify trên driver mysql2).
flowchart TD
R["Request chuyển trạng thái"] --> M{"Chuyển này có trong<br/>bảng next() không?"}
M -- "không" --> E1["Lỗi lập trình — không có endpoint cho nó"]
M -- "có" --> U["UPDATE ... SET state = đích<br/>WHERE id AND state = nguồn AND hạn"]
U --> A{"affected = 1?"}
A -- "0" --> E2["409: đã nộp / quá hạn / sai trạng thái"]
A -- "1" --> D["Ghi dữ liệu kèm (cùng transaction)"]
D --> C["Commit → audit log"]
Cách nói khi phỏng vấn: "Vòng đời một lần làm bài là một máy trạng thái có bảng chuyển trạng thái nằm ở một chỗ duy nhất. Mỗi lần chuyển là một câu cập nhật có điều kiện: đổi sang đã nộp nếu và chỉ nếu đang làm và chưa quá hạn theo đồng hồ của database. Số dòng bị ảnh hưởng bằng không nghĩa là nộp lại, nộp muộn hoặc sai trạng thái, và em trả lỗi rõ ràng. Câu trả lời ghi cùng transaction, nên không bao giờ có bài đã nộp mà mất câu trả lời. Em dùng đồng hồ database vì năm app node có thể lệch giờ nhau."
4.4 Tài nguyên có hạn: UPDATE có điều kiện, khoá dòng cha, unique constraint
// ===== SAI — đọc rồi ghi, có cửa sổ (mục 3.4) =====
$section = ClassSection::find($id);
if ($section->seats_left > 0) {
Enrollment::create(['user_id' => $uid, 'class_section_id' => $id]);
$section->decrement('seats_left');
}
// ===== ĐÚNG (Laravel) — mẫu 1: một dòng, một luật → UPDATE có điều kiện =====
DB::transaction(function () use ($uid, $id) {
$affected = DB::table('class_sections')
->where('id', $id)
->where('seats_left', '>', 0)
->decrement('seats_left');
if ($affected === 0) {
throw new SectionFull($id);
}
// unique(user_id, class_section_id) ở DB: ghi danh trùng → exception → rollback cả decrement
Enrollment::create(['user_id' => $uid, 'class_section_id' => $id]);
});
// ===== ĐÚNG (Laravel) — mẫu 2: luật đếm nhiều dòng → khoá dòng cha làm mutex =====
// Luật: "tối đa 3 lớp mỗi học kỳ" — không có dòng nào để UPDATE có điều kiện
DB::transaction(function () use ($uid, $termId, $id) {
StudentTerm::where('user_id', $uid)->where('term_id', $termId)
->lockForUpdate()->firstOrFail(); // mọi request của SV này xếp hàng ở đây
$count = Enrollment::where('user_id', $uid)->where('term_id', $termId)->count();
if ($count >= 3) {
throw new TermLimitReached();
}
// ... mẫu 1 cho chỗ trống của lớp, rồi tạo enrollment
}, attempts: 3); // retry khi deadlock
// ===== ĐÚNG (NestJS + TypeORM) =====
await this.ds.transaction(async (m) => {
await m.createQueryBuilder(StudentTerm, 't')
.setLock('pessimistic_write')
.where('t.userId = :uid AND t.termId = :termId', { uid, termId })
.getOneOrFail();
const count = await m.count(Enrollment, { where: { userId: uid, termId } });
if (count >= 3) throw new UnprocessableEntityException('Tối đa 3 lớp mỗi học kỳ');
const r = await m.createQueryBuilder().update(ClassSection)
.set({ seatsLeft: () => 'seats_left - 1' })
.where('id = :id AND seats_left > 0', { id })
.execute();
if (!r.affected) throw new ConflictException('Lớp đã đủ chỗ');
await m.insert(Enrollment, { userId: uid, termId, classSectionId: id }); // unique ở DB
});
Giải thích mẫu đúng, từng điểm:
- Chọn cơ chế theo hình dạng của luật — bảng "Pattern Reference" của Business Logic Cheat Sheet, diễn giải:
Hình dạng luật Cơ chế Ví dụ LMS Giảm một bộ đếm trên một dòng, có ngưỡng UPDATE … WHERE còn > 0, kiểm affected Chỗ trống lớp học phần, suất thi Mỗi người chỉ một lần Unique constraint, để DB từ chối Một ghi danh mỗi SV mỗi lớp; một lần nhận thưởng Đọc–sửa–ghi một dòng phức tạp SELECT … FOR UPDATErồi UPDATE trong transactionTính lại điểm tổng của SV Luật đếm trên nhiều dòng (chưa tồn tại) Khoá một dòng cha làm mutex, hoặc SERIALIZABLE + retry Tối đa 3 lớp/kỳ Tác động ra ngoài không lặp được Idempotency key + trạng thái bền vững Gửi email, gọi cổng thanh toán (mục 4.5) - Vì sao mẫu 2 khoá dòng cha thay vì khoá các dòng enrollment. Luật "tối đa 3" phụ thuộc vào dòng chưa tồn tại (lớp
thứ 4 sắp chèn).
FOR UPDATEtrên kết quảcountchỉ khoá các dòng đang có; ở READ COMMITTED, request khác vẫn chèn được dòng mới song song (phantom). Khoá một dòng đại diện cho "SV này trong kỳ này" biến mọi request của cùng SV thành tuần tự — đơn giản, dễ hiểu, chi phí chỉ trên một SV. Phương án thay thế làSERIALIZABLE: cheat sheet nêu "The database will detect conflicts and roll back one of the competing transactions" — đúng mà không cần dòng cha, nhưng phải có retry ở mọi chỗ gọi và tỉ lệ rollback tăng lúc cao điểm. Hành vi phantom ở READ COMMITTED của PostgreSQL/InnoDB: theo docs, chưa verify bằng lab ở bài này. - Unique constraint là chốt cuối, không phải lớp duy nhất. Nó bắt được trùng lặp mà mọi lớp phía trên bỏ sót (code mới quên dùng service, import CSV, script tay). Nhưng nó chỉ biểu diễn được luật "duy nhất", không biểu diễn được "tối đa 3" hay "còn chỗ". Bẫy và cách bắt exception vi phạm unique trong Laravel đã có lab: Chống double-submit mục 1.4 và 3.4.
attempts: 3: docs Laravel — "the number of times a transaction should be retried when a deadlock occurs". Khoá theo thứ tự nhất quán (luôn dòng cha trước, dòng lớp sau) để giảm deadlock ngay từ đầu; retry chỉ là lưới an toàn.- Không dùng Redis lock cho tính đúng của dữ liệu trong DB khi DB tự làm được.
Cache::lockcủa Laravel hữu ích để khỏi chạy trùng một việc tốn kém (rebuild cache, sinh báo cáo), nhưng lock có TTL — job chạy lâu hơn TTL thì hai tiến trình cùng giữ "lock". Phân tích đầy đủ: Chống double-submit mục 3.3. Optimistic locking (cột version) là lựa chọn khi xung đột hiếm và có người dùng để báo "dữ liệu đã đổi, tải lại": Optimistic locking.
flowchart TD
L["Luật nghiệp vụ cần giữ"] --> H{"Hình dạng?"}
H -- "bộ đếm 1 dòng có ngưỡng" --> C["UPDATE có điều kiện<br/>+ kiểm affected"]
H -- "mỗi người 1 lần" --> U["Unique constraint"]
H -- "đếm nhiều dòng" --> P["Khoá dòng cha FOR UPDATE<br/>hoặc SERIALIZABLE + retry"]
H -- "tác động ra ngoài" --> I["Idempotency key<br/>+ trạng thái bền vững"]
C --> Z["Luôn thêm unique constraint<br/>làm chốt cuối nếu áp dụng được"]
P --> Z
Cách nói khi phỏng vấn: "Em chọn cơ chế theo hình dạng của luật. Chỗ trống của lớp là bộ đếm trên một dòng, nên dùng một câu cập nhật có điều kiện còn chỗ và xem số dòng bị ảnh hưởng. Mỗi sinh viên một ghi danh mỗi lớp thì để unique constraint giữ. Luật tối đa ba lớp mỗi kỳ thì không có dòng nào để khoá, vì dòng thứ tư chưa tồn tại, nên em khoá một dòng đại diện cho sinh viên trong kỳ đó để các request của họ xếp hàng. Unique constraint luôn là chốt cuối, vì code mới, import hay script tay đều có thể đi vòng qua service."
4.5 Tác động ra bên ngoài: idempotency thay vì "hy vọng không retry"
1. Gửi email, gọi cổng thanh toán, đẩy điểm sang hệ thống của trường — các tác động này không nằm trong transaction của database mình. Business Logic Cheat Sheet nói thẳng: "A local database transaction alone cannot make an external side effect atomic with saving its result." Hệ quả: hoặc làm hai lần (retry sau timeout), hoặc mất (commit xong rồi tiến trình chết trước khi gửi).
2. Các control thiết kế cheat sheet nêu cho API của chính mình: key "scope stored keys to the authenticated caller and operation", gắn với tham số gốc và "reject reuse with different parameters", "Authorize the request before returning a cached result; possession of a key is not authorization", xử lý đồng thời nguyên tử để chỉ một request bắt đầu hành động, và định nghĩa cửa sổ retry. Với dịch vụ bên ngoài: dùng cơ chế idempotency của chính nhà cung cấp và đối soát kết quả khi không biết bên kia đã làm chưa.
3. KB đã có ba bài sâu cho đúng phần này, nên ở đây chỉ chốt vai trò thiết kế: idempotency key là control phải có trong spec của mọi luồng có retry (mobile mạng yếu, queue at-least-once, webhook gửi lại), không phải tối ưu thêm sau. Chi tiết: Lưu idempotency key ở Redis, Gửi mail at-least-once và exactly-once (outbox, dedupe), Chống double-submit Case 5 (khi client đối địch — key do client sinh thành lỗ hổng).
flowchart TD
R["Request có Idempotency-Key"] --> A["Xác thực + phân quyền TRƯỚC"]
A --> K{"Key (theo user + thao tác)<br/>đã có?"}
K -- "có, cùng tham số" --> C["Trả kết quả đã lưu"]
K -- "có, khác tham số" --> E["422 — key bị dùng lại sai"]
K -- "chưa" --> L["Giành key nguyên tử (SET NX / unique)"]
L --> X["Thực hiện + lưu trạng thái bền vững"]
X --> O["Không rõ kết quả bên ngoài?<br/>đối soát trước khi gửi lại"]
Cách nói khi phỏng vấn: "Tác động ra ngoài như gửi email hay gọi cổng thanh toán không nằm trong transaction của em, nên retry sau timeout có thể làm hai lần. Vì vậy idempotency key là một control phải có ngay trong spec, không phải tối ưu thêm. Key gắn với user và thao tác, gắn với tham số gốc, và em vẫn kiểm quyền trước khi trả kết quả đã lưu, vì có key không có nghĩa là có quyền. Khi không rõ bên kia đã làm chưa thì đối soát trước khi gửi lại."
4.6 Trust boundary trong code: dữ liệu đã kiểm có kiểu riêng
// ===== SAI — dữ liệu chưa kiểm vào session, bước sau coi là đã kiểm (CWE-501) =====
// bước 1
session(['student_code' => $request->input('student_code')]);
// bước 3 — ở file khác, người khác viết
$student = Student::where('code', session('student_code'))->first(); // "đã xác minh" ?
$this->linkAccount(auth()->user(), $student);
// ===== ĐÚNG (Laravel) =====
// Chỉ service xác minh mới tạo được giá trị "đã tin"; tên key nói rõ mức tin cậy
final class VerifiedStudentLink
{
private function __construct(public readonly int $userId, public readonly int $studentId) {}
public static function fromVerification(StudentVerification $v): self
{
// StudentVerification đã đối chiếu mã một lần gửi qua email trường, còn hạn, đúng user
return new self($v->user_id, $v->student_id);
}
}
session(['pending_student_code' => $validated['student_code']]); // chưa tin: tiền tố pending_
// ... sau khi xác minh thành công:
session(['verified_student_link' => VerifiedStudentLink::fromVerification($verification)]);
// ===== ĐÚNG (NestJS) =====
// Guard / service xác minh gắn kết quả vào request với kiểu riêng; controller chỉ nhận kiểu đó
export class VerifiedStudent { constructor(readonly userId: number, readonly studentId: number) {} }
@Post('link-account')
@UseGuards(JwtAuthGuard, StudentVerifiedGuard) // guard tra bảng verification phía server
link(@CurrentVerifiedStudent() vs: VerifiedStudent) { // decorator chỉ đọc giá trị guard đã gắn
return this.accounts.link(vs.userId, vs.studentId);
}
Giải thích mẫu đúng, từng điểm:
- Đặt tên theo mức tin cậy (
pending_*vsverified_*): cách rẻ nhất để người viết bước 3 — có thể là người khác, sáu tháng sau — không nhầm. Đây là "control" ở mức quy ước, review bắt được. - Kiểu riêng với constructor private (PHP) / class riêng được guard gắn vào (NestJS): chỉ con đường xác minh mới tạo ra được
giá trị đã tin. Code downstream nhận
VerifiedStudentLinkchứ không nhậnstring, nên không thể vô tình truyền input thô vào. Trade-off: thêm vài class; đáng làm cho các luồng liên kết danh tính, nâng quyền, xác nhận thanh toán — không cần cho mọi form. - Decorator
@CurrentVerifiedStudent()là custom param decorator đọc giá trị guard đã gắn vào request — nó không đọc body. Nếu decorator lấy từreq.bodythì toàn bộ thiết kế vô nghĩa; đây là điểm review phải nhìn. - Luồng xác minh bản thân (mã một lần qua email trường, hạn ngắn, gắn đúng user) là chủ đề của Bài 7; ở đây chỉ bàn ranh giới giữa "đã xác minh" và "chưa".
flowchart TD I["Input: student_code"] --> P["session pending_student_code<br/>(chưa tin)"] P --> V["Service xác minh: mã một lần,<br/>còn hạn, đúng user"] V -- "ok" --> T["VerifiedStudentLink<br/>(chỉ service này tạo được)"] V -- "sai" --> X["Từ chối, xoá pending"] T --> D["Code downstream chỉ nhận kiểu đã tin"]
Cách nói khi phỏng vấn: "Lỗi trust boundary hay gặp là bước một cất input vào session để dùng sau, rồi bước ba ở file khác coi giá trị đó là đã xác minh. Em tách bằng hai cách: tên key nói rõ mức tin cậy, chưa tin thì có tiền tố pending, và giá trị đã tin có kiểu riêng mà chỉ service xác minh mới tạo ra được. Code phía sau nhận kiểu đó chứ không nhận chuỗi thô, nên không thể vô tình truyền input chưa kiểm vào."
5. Phòng thủ nhiều lớp — quy trình và kiến trúc
Mục 4 là control trong code. Nhưng định nghĩa của A06 là control không tồn tại, nên phòng thủ chính nằm ở quy trình làm ra spec và ở các thành phần dựng sẵn — thứ quyết định control có được nghĩ tới hay không.
5.1 Bảng tổng hợp
| Lớp | Chặn được gì | KHÔNG chặn được gì | Hợp với bối cảnh |
|---|---|---|---|
| Threat modeling (mục 5.2) | Control bị thiếu ở luồng quan trọng; trust boundary sai | Lỗi cài đặt; luồng không được đưa vào buổi threat modeling | Tính năng chạm tiền, điểm, quyền, PII, gọi ra ngoài |
| Abuse case / invariant trong story (5.3) | Luật nghiệp vụ bị bỏ quên; hiểu nhầm giữa PO và dev | Thứ không ai nghĩ tới trong buổi viết story | Mọi story có luồng nhiều bước hoặc phát giá trị |
| Paved road / thành phần dựng sẵn (5.4) | Dev mới quên control chuẩn (idempotency, state machine, quota) | Luật riêng của từng tính năng | Team ≥ 4–5 dev, codebase dùng chung nhiều module |
| Validation + tính lại ở server (4.1) | Giá trị gian lận từ client | Request hợp lệ nhưng quá nhiều, quá nhanh, song song | Luôn luôn |
| Ghi có điều kiện / khoá / unique (4.3, 4.4) | Race condition, lặp bước, vượt số lượng | Luật chưa được viết thành điều kiện | Mọi check-then-act |
| Rate limit + quota theo tính năng (4.2) | Lạm dụng khối lượng, bot | Một request gian lận đơn lẻ | Tính năng phát giá trị, gửi ra ngoài |
| Test invariant / concurrency (5.6) | Hồi quy: luật đã có bị code mới phá | Luật chưa từng được viết ra | Luồng quan trọng, chạy trong CI |
| Giám sát nghiệp vụ (6.3) | Lạm dụng mới mà thiết kế chưa lường; phát hiện sau | Ngăn chặn trước — chỉ phát hiện | Production, tính năng phát giá trị |
5.2 Threat modeling thực dụng cho team 5–7 người
1. Khung bốn câu hỏi của Threat Modeling Manifesto, được OWASP Threat Modeling Cheat Sheet dùng làm xương sống: "What are we working on? What can go wrong? What are we going to do about it? Did we do a good enough job?" Bốn câu này ứng với bốn bước: mô hình hoá hệ thống, nhận diện và xếp hạng mối đe doạ, chọn phản ứng, rà soát lại.
2. Bước 1 — vẽ DFD (data flow diagram — sơ đồ luồng dữ liệu) với trust boundary. Cheat sheet: DFD là cách phổ biến nhất, lưu ở dạng team sửa được; với người không chuyên kỹ thuật (PO, BA) có thể brainstorming thay cho ký hiệu DFD chuẩn. Ví dụ cho luồng nộp bài của một LMS có kiến trúc giống LMS Uni (minh hoạ — các khối lấy từ CV, mũi tên là giả định):
flowchart TD
subgraph Z0["Internet — không tin cậy"]
SV["Trình duyệt sinh viên"]
end
subgraph Z1["Biên — Nginx LB"]
LB["Nginx load balancer"]
end
subgraph Z2["Vùng ứng dụng"]
MO["Moodle (5 app node)"]
PO["Student portal NestJS"]
WK["Worker chấm điểm / gửi thông báo"]
end
subgraph Z3["Vùng dữ liệu"]
DB["MySQL primary / replica"]
RD["Redis (session, cache, lock)"]
NFS["NFS moodledata"]
end
SV -- "(1) nộp bài qua HTTPS" --> LB
LB --> MO
LB --> PO
MO -- "(2) ghi bài nộp" --> DB
PO -- "(3) đọc / ghi chung DB *" --> DB
MO -- "(4) xếp job" --> RD
WK -- "(5) lấy job, ghi điểm" --> DB
MO -- "file đính kèm" --> NFS
Cách nói khi phỏng vấn: "Bước đầu của threat modeling là vẽ luồng dữ liệu và các đường ranh giới tin cậy. Với luồng nộp bài: trình duyệt ở Internet, qua load balancer, vào vùng ứng dụng gồm Moodle, portal và worker, rồi xuống vùng dữ liệu là MySQL, Redis và NFS. Mỗi mũi tên đi qua một ranh giới là một chỗ em phải hỏi kẻ xấu có thể làm gì ở đó. Mũi tên đáng chú ý nhất là portal ghi chung database với Moodle, vì nó có thể đi vòng qua luật của Moodle."
3. Bước 2 — hỏi "cái gì có thể sai" bằng STRIDE (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege — sáu nhóm, mỗi nhóm vi phạm một thuộc tính: xác thực, toàn vẹn, truy vết, bảo mật, sẵn sàng, phân quyền). Áp vào luồng trên (minh hoạ):
| STRIDE | Câu hỏi ở luồng nộp bài | Control (mục) |
|---|---|---|
| Spoofing | Có nộp được bài thay người khác không (attempt_id của người khác)? | Ownership trong query (4.1, Bài 1) |
| Tampering | Có sửa được điểm, thời điểm bắt đầu, hạn nộp không? | Server tự tính (4.1), state machine (4.3) |
| Repudiation | SV nói "em đã nộp lúc 23:59" — có bằng chứng phía server không? | submitted_at theo đồng hồ DB, audit log (Bài 9) |
| Information Disclosure | Đáp án có lộ ra client trước khi hết hạn không? | Đáp án chỉ ở server; API không trả nguyên object (ASVS 15.3.1) |
| Denial of Service | Cuối hạn, request nộp bài + job chấm dồn có làm sập hàng đợi không? | Tài liệu hoá chức năng tốn tài nguyên, queue (ASVS 15.1.3) |
| Elevation of Privilege | Portal ghi thẳng bảng điểm của Moodle có vượt capability check không? | Ghi qua API của chủ dữ liệu (mục 7.1) |
4. Chọn kỹ thuật theo phạm vi — cheat sheet có bảng gợi ý, và cảnh báo "Do not add techniques only to make the model appear comprehensive". Tóm lược cho team ứng dụng web: STRIDE cho thiết kế kỹ thuật; misuse/abuse case khi cần soi "Abuse of a valid workflow or business rule" — hành động sai thứ tự, lặp lại, đồng thời, hoặc nhằm kết quả không định trước (đúng phần lõi của A06); attack tree khi có một mục tiêu giá trị cao nhiều bước; LINDDUN khi trọng tâm là quyền riêng tư (dữ liệu sinh viên). PASTA, OCTAVE, VAST dành cho quy mô tổ chức — quá nặng cho team 5–7 người.
5. Bước 3 — phản ứng cho từng mối đe doạ, theo danh sách của Adam Shostack mà cheat sheet dẫn: Mitigate (giảm khả năng xảy ra), Eliminate (bỏ tính năng gây ra nó), Transfer (chuyển trách nhiệm theo hợp đồng — cheat sheet nhấn mạnh việc chuyển không tự làm giảm rủi ro), Accept (chấp nhận, kèm lý do ghi lại). Kết quả phải thành "actionable security requirements" — tức là story hoặc acceptance criteria, không phải một file PDF.
6. Khi nào đáng làm, khi nào không (đã bàn ngắn ở Bài 0 mục 5.1; ở đây chi tiết hơn). Đáng: tính năng chạm vào tiền, điểm, chứng chỉ, quyền, dữ liệu cá nhân, tích hợp hệ thống ngoài, hoặc thêm/bớt một kết nối mới trong kiến trúc (Secure Product Design Cheat Sheet: "Adding (or removing) connections is probably a sign that Product Inception is happening"). Không đáng làm buổi riêng: CRUD nội bộ ít rủi ro, đổi giao diện — checklist review là đủ. Cách giữ chi phí thấp (số minh hoạ): time-box 45 phút trong refinement, người tham gia là dev làm tính năng + tech lead + BA, đầu ra là 2–5 security story. OWASP SAMM xếp đây là mức trưởng thành 1 của stream Threat Modeling: "best-effort, risk-based threat modeling using brainstorming and existing diagrams with simple threat checklists" — mức hợp lý để bắt đầu, không cần công cụ.
7. Trade-off thật: threat modeling tốn thời gian của người giỏi nhất team, dễ thành thủ tục "điền cho có", và chỉ tốt bằng những người ngồi trong phòng. Cheat sheet thừa nhận: "many developers lack sufficient knowledge and experience in the field of security". Đó cũng là lý do bản 2025 thêm dòng dùng threat modeling như công cụ giáo dục: giá trị lớn nhất với team nhỏ thường không phải danh sách mối đe doạ, mà là dev bắt đầu tự hỏi "nếu người dùng không trung thực thì sao" ở story tiếp theo.
flowchart TD
ST["Story mới trong refinement"] --> Q{"Chạm tiền / điểm / quyền / PII<br/>hoặc thêm kết nối mới?"}
Q -- "Không" --> CL["Checklist review là đủ"]
Q -- "Có" --> TM["Threat modeling 45 phút<br/>DFD + STRIDE / abuse case"]
TM --> RS["Mỗi mối đe doạ: mitigate / eliminate<br/>/ transfer / accept (ghi lý do)"]
RS --> SS["2–5 security story +<br/>acceptance criteria có invariant"]
SS --> TS["Test cho từng invariant (5.6)"]
TS -. "sự cố / postmortem" .-> TM
Cách nói khi phỏng vấn: "Em không làm threat modeling cho mọi story, vì nó tốn thời gian người giỏi nhất team và dễ thành thủ tục. Em chỉ làm khi tính năng chạm vào điểm, tiền, quyền, dữ liệu cá nhân, hoặc thêm một kết nối mới trong kiến trúc. Buổi đó khoảng bốn mươi lăm phút: vẽ luồng dữ liệu, đi qua sáu nhóm STRIDE hoặc các cách lạm dụng luồng nghiệp vụ, rồi mỗi mối đe doạ phải có quyết định giảm, bỏ tính năng, chuyển trách nhiệm hay chấp nhận có lý do. Đầu ra là vài story bảo mật có tiêu chí chấp nhận, không phải một tài liệu dài."
5.3 Abuse case, misuse case và invariant trong user story
1. OWASP Top 10 yêu cầu "Integrate security language and controls into user stories" và "Compile use-cases and misuse-cases for each tier of your application". Nhưng cần biết một chi tiết người làm lâu mới để ý: chính OWASP Cheat Sheet Series đã đưa Abuse Case Cheat Sheet vào lưu trữ ("Historical"), với lý do "Reviewers have identified that abuse cases are rarely used in practice", và tài liệu mang tính hướng dẫn nhập môn hơn là cheat sheet. Hai nguồn của cùng OWASP nhìn khác nhau: Top 10 vẫn khuyên misuse case; Cheat Sheet thừa nhận dạng "workshop abuse case đầy đủ" ít ai duy trì được.
2. Cách em dung hoà (quan điểm tổng hợp, không phải chuẩn của OWASP): giữ ý của abuse case, bỏ nghi thức. Business Logic Cheat Sheet có hai bài tập nhẹ hơn và hợp với team sản phẩm:
- "Dishonest user": tưởng tượng một người dùng chỉ muốn lấy tối đa giá trị "while staying within the letter of the system's rules" — không gửi payload độc hại, chỉ làm đúng các thao tác hợp lệ nhưng theo thứ tự hay khối lượng không ai lường.
- Viết invariant bằng lời, rồi với mỗi invariant "identify exactly what code enforces it. If the answer is 'the UI' or 'the user won't try that', the invariant isn't actually enforced."
3. Ví dụ acceptance criteria cho story "Sinh viên nộp bài quiz" (minh hoạ, viết được bởi BA cùng dev):
| Invariant (lời) | Ai/cái gì thực thi | Test |
|---|---|---|
| Một lần làm bài chỉ được nộp một lần | UPDATE có điều kiện state = in_progress | Hai request nộp song song → đúng 1 thành công |
Không nộp được sau deadline_at + ân hạn | Điều kiện thời gian theo đồng hồ DB | Nộp lúc hạn + 1 giây → 409 |
| Điểm chỉ do server tính | DTO whitelist, grader server | Gửi kèm score → 400 |
| Chỉ chủ lần làm bài nộp được | Ownership trong query | User B nộp attempt của A → 404 |
| Không ai thấy đáp án trước khi công bố | API trả DTO riêng, không trả entity | Response của quiz đang mở không có trường đáp án |
Cách nói khi phỏng vấn: "OWASP Top 10 vẫn khuyên viết misuse case, nhưng chính bộ cheat sheet của OWASP đã cho tài liệu abuse case vào lưu trữ vì thực tế ít ai duy trì. Em giữ ý tưởng, bỏ nghi thức: trong story em viết các bất biến bằng lời, như mỗi lần làm bài chỉ nộp một lần, không nộp sau hạn, điểm chỉ do server tính. Mỗi bất biến phải chỉ ra được đoạn code hoặc ràng buộc nào giữ nó và test nào chứng minh; nếu câu trả lời là giao diện hay người dùng sẽ không làm vậy, thì bất biến đó chưa được giữ."
5.4 Paved road, secure defaults và thư viện component an toàn
1. OWASP: "Establish and use a library of secure design patterns or paved-road components". Ý tưởng: thay vì dặn mỗi dev nhớ mười control, làm cho con đường dễ nhất cũng là con đường an toàn. Secure Product Design Cheat Sheet đặt nền: "establish secure defaults, minimize the attack surface area, and fail securely to those well-defined and understood defaults". OWASP SAMM, practice Secure Architecture, mức 2: "Establish common design patterns and security solutions for adoption"; mức 3: dùng reference architecture.
2. Paved road cụ thể cho codebase Laravel dùng chung (gợi ý, khớp với kiến trúc modular monolith trong CV): một base
FormRequest bắt buộc dùng validated(); một trait/service Transitions cho state machine (mục 4.3) để không ai phải tự viết
UPDATE có điều kiện; middleware idempotency gắn bằng một dòng route; helper quota (mục 4.2); global scope tenant (Bài 1 mục 5.3). Với NestJS: global
ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) đặt một lần ở main.ts hoặc qua APP_PIPE, guard
mặc định deny. Cách tổ chức codebase dùng chung: Codebase dùng chung.
3. Trade-off: paved road cần người bảo trì; một thành phần dùng chung có lỗi là lỗi ở mọi chỗ dùng (4 trường cùng lúc); và nếu đường lát sẵn bất tiện, dev sẽ đi đường tắt — nên đo bằng "bao nhiêu tính năng mới dùng nó", và review phải hỏi vì sao không dùng. Không hợp: team 2–3 người, ít tính năng nhạy cảm — chi phí xây thành phần chung lớn hơn lợi.
5.5 Bảo mật trong Definition of Done
Câu hỏi thêm vào Definition of Done (DoD — điều kiện để một story được coi là xong) cho story có rủi ro, rút từ "Summary Checklist" của Business Logic Cheat Sheet và ASVS V2 (diễn giải):
- Mọi giá trị ảnh hưởng tới giá, điểm, quyền, danh tính, ownership đều được server tính/tra lại?
- Luồng nhiều bước có state machine ở server, mỗi chuyển được kiểm?
- Mọi thao tác kiểm-rồi-làm đều nguyên tử (UPDATE có điều kiện, khoá, unique)?
- Tác động ra ngoài có idempotency và cửa sổ retry rõ ràng?
- Tính năng phát giá trị có trần theo lần, theo tài khoản, rate limit?
- Invariant đã viết ra và có test?
- Mọi entry point (web, API, import, webhook, lệnh artisan) của thao tác nhạy cảm áp cùng luật? — cheat sheet: "Map every sensitive operation to every entry point that can trigger it".
- Log đủ để dựng lại khi bị lạm dụng, có alert theo tốc độ bất thường?
Trade-off: tám câu hỏi cho mọi story là quá nặng và sẽ bị đánh dấu cho có. Gắn DoD mở rộng này chỉ vào story được gắn nhãn rủi ro ở refinement (cùng tiêu chí với threat modeling, mục 5.2 đoạn 6).
5.6 Test cho invariant, thứ tự và đồng thời
1. Business Logic Cheat Sheet: "Focus test effort on the rules, not the happy path", với ba loại test: invariant ("write a test that would fail if the invariant were violated"), ordering ("test attempts to skip, repeat, and reorder steps. The expected outcome for each is a rejection"), concurrency ("If two requests can race, write a test that races them").
// Laravel (Pest) — test thứ tự: mọi chuyển trạng thái không có trong next() phải bị từ chối
it('rejects every transition not in the table', function (AttemptState $from, AttemptState $to) {
$attempt = Attempt::factory()->state(['state' => $from->value])->create();
expect(fn () => app(AttemptTransitions::class)->to($attempt, $to))
->toThrow(InvalidTransition::class);
})->with(fn () => collect(AttemptState::cases())
->crossJoin(AttemptState::cases())
->reject(fn ($p) => in_array($p[1], $p[0]->next(), true))
->all());
// Test đồng thời: không chạy được bằng 2 lời gọi tuần tự trong cùng transaction test.
// Cần 2 kết nối DB thật (2 process / 2 connection), DB thật (không SQLite in-memory),
// và assert trên trạng thái cuối: đúng 1 enrollment, seats_left không âm.
Cách nói khi phỏng vấn: "Với máy trạng thái em sinh test tự động: lấy mọi cặp trạng thái nguồn và đích, bỏ những cặp có trong bảng chuyển hợp lệ, phần còn lại đều phải bị từ chối. Như vậy thêm một trạng thái mới là test tự mở rộng. Test đồng thời thì khó hơn: phải chạy hai kết nối database thật song song, không dùng SQLite trong bộ nhớ, và kiểm trạng thái cuối, ví dụ đúng một ghi danh và số chỗ không âm."
2. Bẫy khi test concurrency trong Laravel: trait RefreshDatabase bọc mỗi test trong một transaction; hai "request"
trong cùng test dùng chung kết nối đó nên không bao giờ tranh khoá với nhau — test xanh mà không chứng minh gì. Cần kết nối thứ hai hoặc tiến
trình con (bài Chống double-submit có lab race bằng nhiều process). SQLite in-memory không có
FOR UPDATE theo cùng ngữ nghĩa — test khoá phải chạy trên đúng engine production (MySQL/PostgreSQL). Hai nhận định này theo hiểu biết
về cách Laravel test và SQLite hoạt động, chưa verify riêng ở bài này.
5.7 Nếu chỉ có một sprint: thứ tự ưu tiên
- Liệt kê các thao tác check-then-act trên tài nguyên có hạn hoặc phát giá trị (grep
if (… > 0)rồidecrement,exists()rồicreate) → đổi sang UPDATE có điều kiện + unique constraint. Lỗi loại này xảy ra cả với người dùng trung thực lúc cao điểm. - Tìm giá trị dẫn xuất nhận từ client (giá, điểm, thời điểm, user_id trong body) → bỏ khỏi DTO/FormRequest, tính lại ở server; bật whitelist toàn cục.
- Luồng nhiều bước quan trọng nhất (nộp bài, ghi danh, công bố điểm) → state machine với điều kiện trạng thái nguồn.
- Trần + rate limit cho tính năng gửi email / thông báo hàng loạt.
- Thêm câu hỏi "tính năng này phát ra giá trị gì, ai không trung thực sẽ làm gì" vào template story — thay đổi rẻ nhất, tác dụng dài nhất.
6. Phát hiện & vận hành
6.1 Design review và code review: câu hỏi cần hỏi
Lỗi thiết kế không có "dòng code sai" để chỉ vào, nên review phải hỏi về cái vắng mặt. Bộ câu hỏi em dùng (tổng hợp từ Business Logic Cheat Sheet "Questions to Ask Early" và ASVS V2, V15):
| Hỏi gì | Dấu hiệu trong diff | Nếu thiếu → mục |
|---|---|---|
| Giá trị này ai tính? Có field nào trong DTO/FormRequest là kết luận chứ không phải ý định? | price, score, total, user_id, role, *_at trong rules/DTO | 4.1 |
| Có đoạn đọc–quyết định–ghi nào không? | if ($x->… > 0) rồi save/decrement; exists() rồi create(); findOne rồi save | 3.4, 4.4 |
| Endpoint này có kiểm trạng thái nguồn không? Gọi hai lần thì sao? | update(['state' => …]) không có where('state', …) | 3.3, 4.3 |
| Tính năng phát ra gì có giá trị? Trần ở đâu? | Endpoint gửi email/thông báo, tạo mã, cấp điểm, không có throttle/quota | 3.7, 4.2 |
| Có entry point thứ hai cho cùng thao tác không (import, webhook, lệnh artisan, API v1)? | Service được gọi từ chỗ mới mà không qua cùng luật | 5.5 câu 7 |
| Dữ liệu này vượt ranh giới tin cậy nào? Chỗ nhận có coi nó là đã kiểm không? | session([...$request->…]), ghi thẳng bảng của hệ thống khác | 3.6, 4.6 |
| Lỗi giữa chừng thì dữ liệu ở trạng thái nào? | Nhiều lần ghi không có transaction; gửi email trong transaction | 4.3, 4.5, Bài 10 |
6.2 Công cụ tự động phủ được gì
1. OWASP (Bài 0) nói A06 "is beyond the scope of most forms of automated testing"; Business Logic Cheat Sheet: "Generic scanners often miss business logic flaws because they lack the application's business rules", nhưng thêm rằng phân tích tự động "can still detect some patterns, including workflow enforcement weaknesses". Thực tế cho team:
- SAST bắt được một phần các CWE có dấu hiệu kỹ thuật trong nhóm này: lưu mật khẩu rõ (CWE-256), hàm nguy hiểm (CWE-676),
upload không kiểm (CWE-434). Có thể viết rule riêng cho mẫu của team, ví dụ cấm field tên
score/pricetrong DTO nhận từ client, hoặc cảnh báoupdate(['state' =>không có điều kiệnstate— đây là cách biến bài học thành control tự động. Không bắt được luật nghiệp vụ chưa viết ra. - DAST bắt được clickjacking thiếu header (CWE-1021), thông tin nhạy cảm trong query string (CWE-598), cookie lâu dài chứa dữ liệu nhạy cảm (CWE-539). Không biết "nộp hai lần" là sai.
- Pentest có hiểu nghiệp vụ (OWASP Web Security Testing Guide có hẳn phần Business Logic Testing) là cách tự động hoá… bằng người: đắt, theo đợt, nên dành cho trước các mốc lớn (mở đăng ký học kỳ, mở bán).
6.3 Tín hiệu trong production: giám sát invariant, không chỉ giám sát lỗi
1. Log cho thao tác phát giá trị. Cheat sheet liệt kê: người thực hiện, tài nguyên đích, hành động, kết quả, ngữ cảnh request (IP, user agent, correlation ID) và ngữ cảnh nghiệp vụ (giá đã tính, mã đã áp, trạng thái mới), log nên "tamper-evident and separate from general application logs". Thiết kế log và alert: Bài 9.
2. Alert theo tốc độ nghiệp vụ — cheat sheet: "You don't need sophisticated machine learning for this. Simple thresholds on per-user, per-IP, and per-device rates catch most automated abuse." Ví dụ LMS (ngưỡng minh hoạ): số lời mời gửi mỗi giờ của một giảng viên gấp 10 lần trung vị; số ghi danh hoàn tất dưới 2 giây kể từ khi mở trang ("Workflows that complete in substantially less time than a human would need"); số lần bị từ chối vì "đã nộp" (409) tăng đột biến trên một quiz — vừa có thể là dò, vừa có thể là bug giao diện gửi lặp.
3. Kiểm invariant định kỳ trên dữ liệu thật: một job hằng đêm đếm những thứ không bao giờ được xảy ra — lớp có số
ghi danh vượt sĩ số, số chỗ còn lại âm, lần làm bài có nhiều hơn một bản nộp, bài nộp sau deadline_at + ân hạn mà không có ngoại lệ
được duyệt. Kết quả khác 0 là một sự cố, không phải một con số trên dashboard. Đây là lớp phát hiện cho chính những control mục 4 — nếu có ai đi
vòng (import, script tay), job sẽ thấy. (Câu SQL cụ thể phụ thuộc schema; không viết ở đây vì bài không chạy được lab để kiểm.)
4. Đóng vòng. Cheat sheet: "A detection that doesn't lead to a prevention is a detection you'll be making again next month." OWASP 2025 thêm "incident post-mortems that are used to improve the process" vào SDL. Với A06 nghĩa là: mỗi postmortem lạm dụng nghiệp vụ phải sinh ra một invariant mới trong spec, một test, và nếu là mẫu lặp lại thì một thành phần paved road.
flowchart TD
P["Production"] --> L["Audit log thao tác phát giá trị"]
P --> J["Job hằng đêm đếm vi phạm invariant"]
L --> A{"Tốc độ bất thường?"}
J --> B{"Vi phạm khác 0?"}
A -- "có" --> I["Điều tra sự cố"]
B -- "có" --> I
I --> PM["Postmortem"]
PM --> S["Invariant mới trong spec + test"]
S --> PR["Paved road nếu là mẫu lặp lại"]
PR -.-> P
Cách nói khi phỏng vấn: "Với lỗi thiết kế, em không chỉ theo dõi lỗi 500 mà theo dõi invariant. Có job hằng đêm đếm những thứ không bao giờ được xảy ra: lớp vượt sĩ số, số chỗ âm, một lần làm bài có hai bản nộp. Khác không là sự cố. Thêm vào đó là alert theo tốc độ nghiệp vụ, như một giảng viên gửi lời mời gấp mười lần bình thường, hay ghi danh xong nhanh hơn người thật làm được. Và mỗi postmortem phải đẻ ra một invariant mới trong spec và một test, nếu không tháng sau sẽ gặp lại."
7. Áp dụng vào hệ thống trong CV
Nguồn: cv-v3.md. Dấu * = giả định, CV không nêu. Không bịa thông tin về hệ thống — chỗ nào chưa biết thì nêu thành câu hỏi để tự kiểm
lại trước buổi phỏng vấn.
7.1 LMS Uni — Moodle, student portal NestJS + Next.js dùng chung MySQL, Redis, Nginx LB, 5 app node, worker trên nhiều VPS
- Hạn nộp lúc cao điểm và hàng đợi. CV: ~400 request/giây dồn vào cuối hạn; backlog ~40.000 job xử lý trong ~20 phút sau khi scale out worker (trước đó ~3 giờ). Câu hỏi thiết kế interviewer rất có thể đào: "nếu bài nộp được xử lý qua queue, sinh viên bấm nộp lúc 23:59 mà job chạy lúc 00:15 thì tính đúng hạn hay trễ?" Câu trả lời đúng: thời điểm nộp phải được ghi lúc server nhận (trong request, theo đồng hồ tin cậy), còn job chỉ chấm điểm — không bao giờ để hàng đợi quyết định "có trễ không". Nếu trước kia không như vậy, đó là một lỗi thiết kế thật đáng kể như bài học. CV liệt kê worker "chấm điểm" nhưng không nói worker có ghi lại thời điểm nộp hay không* — tự kiểm lại.
- Moodle đã có luật hạn nộp riêng. Quiz của Moodle có thời điểm đóng, giới hạn thời gian và cách xử lý khi hết giờ (tự nộp, ân hạn, bỏ) — theo hiểu biết về cài đặt quiz của Moodle, chưa verify ở bài này. Ý thiết kế: portal hay plugin tự viết không được tự tính lại hạn theo cách khác Moodle, vì hai nơi tính hạn khác nhau là hai câu trả lời khác nhau cho cùng một sinh viên.
- Portal NestJS dùng chung database với Moodle — trust boundary quan trọng nhất của hệ thống. Nếu portal chỉ đọc (danh sách khoá học, điểm, tiến độ)*, rủi ro chủ yếu là lộ dữ liệu (Bài 1). Nếu portal ghi vào bảng Moodle*, nó đi vòng qua những gì Moodle làm khi ghi qua API của chính nó: kiểm capability, ghi event/log, tính lại điểm tổng, xoá cache. Thiết kế an toàn hơn: portal ghi qua Moodle Web Services (external functions) hoặc một plugin cung cấp API hẹp, để luật vẫn ở một chỗ. Trade-off: chậm hơn và phụ thuộc Moodle — đó có thể là lý do thực tế đã chọn ghi thẳng; nếu vậy, hãy chủ động nói đã giới hạn bảng nào portal được ghi và kiểm lại invariant thế nào. Cách Moodle Web Services kiểm capability: chưa verify ở bài này.
- Worker chấm điểm chạy trên nhiều VPS — queue thường giao job ít nhất một lần (at-least-once), nên một bài có thể được chấm hai lần.
Ghi điểm phải là chuyển trạng thái có điều kiện
submitted → graded(mục 4.3), không phải "ghi đè điểm". Bài liên quan: Scale out queue worker. - Gửi thông báo hàng loạt cuối hạn (worker "gửi thông báo" trong CV): trần theo lần và theo ngày, dedupe theo người nhận + loại thông báo, idempotency khi retry (mục 4.2, 4.5). Thiết kế chi tiết: Gửi mail cho 1 triệu user, at-least-once và exactly-once.
- Link tải báo cáo xác thực bằng JWT (CV): quyết định thiết kế là link sống bao lâu, gắn với user nào, có dùng lại được không, ai nhận email chuyển tiếp thì tải được không. Đó là A06 (chọn bằng chứng truy cập — giống mục 3.5); cách ký và kiểm token là Bài 4, ai được tải báo cáo nào là Bài 1.
- Redis cho distributed lock (CV): nói rõ trong phỏng vấn lock dùng cho việc gì. Lock có TTL phù hợp để tránh làm trùng việc tốn kém (rebuild cache, sinh báo cáo); tính đúng của dữ liệu (một bài nộp một lần, không vượt sĩ số) vẫn phải do database giữ — mục 4.4 đoạn 5.
7.2 LMS Micro-learning — Laravel + Livewire, NestJS, Next.js, PostgreSQL, Redis, 4 trường
- Idempotency key (Redis SET NX + unique constraint) — CV: bản ghi nộp bài / đăng ký trùng từ ~30 vụ mỗi tháng về 0. Kể nó như một control thiết kế suy ra từ invariant "mỗi thao tác chỉ ghi một lần": Redis là đường nhanh chặn retry, unique constraint là chỗ invariant thực sự được giữ. Câu hỏi tiếp theo thường là "Redis mất key thì sao" — trả lời: unique constraint vẫn chặn, chỉ là người dùng nhận lỗi trùng thay vì kết quả cũ (bài Chống double-submit).
- "Đặt toàn vẹn dữ liệu ở tầng DB (constraint, unique index, partial index)" — CV. Đây chính là mục 4.4 đoạn 3. Ví dụ đáng nói: partial unique index cho luật "mỗi sinh viên chỉ có một ghi danh đang hoạt động mỗi khoá" (unique trên cặp sinh viên–khoá với điều kiện trạng thái đang hoạt động) — luật mà unique thường không biểu diễn được vì sinh viên được phép có ghi danh cũ đã huỷ. Hành vi partial unique index của PostgreSQL theo docs, chưa chạy ở bài này.
- Một codebase cho 4 trường — OWASP: "Segregate tenants robustly by design throughout all tiers". Hạn mức (mục 4.2) cũng phải theo tenant, để một trường gửi thông báo hàng loạt không ăn hết hạn mức của ba trường còn lại. Lớp cô lập ở database (RLS): Bài 1 mục 5.3.
- Livewire: public property là input do client gửi lại — mọi giá trị dẫn xuất (điểm, tiến độ, bước hiện tại) phải được tính lại ở server (mục 3.2): Livewire public property.
- Spec-driven development với AI coding assistant (CV: viết spec → review spec → code). Đây là chỗ đặt control A06 tự nhiên nhất: buổi review spec chính là design review. Code do AI sinh ra bám rất sát spec — spec chỉ có happy path thì code chỉ có happy path. Câu nên nói: "template spec của team có mục luồng lỗi, invariant và giới hạn; review spec hỏi người không trung thực sẽ làm gì" — chỉ nói nếu đúng là đã làm.
- "Chuyển yêu cầu nghiệp vụ từ PO/BA thành thiết kế kỹ thuật, trình bày trade-off" (CV, cả hai dự án). Khi interviewer hỏi về Insecure Design, đây là bullet để neo câu trả lời: vai trò tech lead đứng giữa BA và dev chính là nơi luồng lỗi, giới hạn và luật đồng thời được thêm vào trước khi có code.
7.3 Trả lời mẫu
Cách nói khi phỏng vấn: "Với em Insecure Design là control chưa bao giờ được thiết kế, nên em chặn nó ở khâu chuyển yêu cầu thành thiết kế, chỗ em đứng giữa BA và dev. Ở LMS, các luật quan trọng là nộp bài một lần, không nộp sau hạn, không vượt sĩ số, không gửi thông báo vô hạn. Mỗi luật em biến thành thứ database giữ được: câu cập nhật có điều kiện cho chuyển trạng thái, unique constraint cho mỗi người một lần, hạn mức đếm nguyên tử. Thời điểm nộp ghi lúc server nhận chứ không phải lúc worker xử lý, vì cuối hạn hàng đợi có thể trễ hai mươi phút. Và có job hằng đêm đếm vi phạm invariant để biết nếu có đường nào đi vòng."
8. Anti-patterns & bài học thực tế
8.1 Sự cố công khai: điều gì sai và control nào giúp
| Sự cố | Điều gì sai (theo nguồn công khai) | Control nào giúp |
|---|---|---|
| Flexcoin (2014) | Theo thông báo của chính Flexcoin (Ars Technica, Computerworld dẫn lại): lỗi trong code chuyển tiền giữa người dùng; gửi "thousands of simultaneous requests" để chuyển coin "until the sending account was overdrawn, before balances were updated"; mất 896 BTC (~600.000 USD lúc đó), công ty đóng cửa. Poloniex bị lỗi rút tiền tương tự cùng thời điểm | Trừ số dư bằng UPDATE có điều kiện hoặc khoá dòng (mục 4.4); invariant "số dư không âm" bằng CHECK constraint; job đếm vi phạm (6.3) |
| Starbucks gift card (2015) | Egor Homakov (Sakurity) báo luồng chuyển tiền giữa thẻ quà tặng có race condition: hai lần chuyển song song từ cùng một thẻ đều được ghi nhận; ông nhận xét các "bảo vệ" hay gặp như giới hạn request, delay ngẫu nhiên, "using DB transactions in a wrong way" là không đủ. Liên hệ được bộ phận kỹ thuật rất khó, sửa trong khoảng 10 ngày sau khi liên hệ được | Gộp kiểm và ghi nguyên tử; state machine của luồng hai bước gắn với tài nguyên, không gắn với session; kênh tiếp nhận báo lỗi bảo mật rõ ràng |
| Tài khoản Yahoo Mail của Sarah Palin (2008) | Theo các mô tả công khai: tài khoản bị chiếm qua luồng đặt lại mật khẩu dựa trên câu hỏi bảo mật có câu trả lời tra được từ thông tin công khai (ngày sinh, mã bưu chính…) | Đúng kịch bản số 1 của OWASP: bỏ câu hỏi bảo mật, thay bằng thiết kế khôi phục dựa trên thứ người dùng sở hữu (Bài 7) |
| Bot mua hàng giới hạn (OWASP kịch bản số 3, không nêu tên công ty) | Không có luật nghiệp vụ chống mua tự động khi mở bán | Luật theo thời gian người thật (ASVS 2.4.2), trần theo tài khoản/phương thức thanh toán, hàng đợi công bằng |
Điểm chung đáng nói khi phỏng vấn: cả hai sự cố về tiền đều là check-then-act trên số dư — mẫu lỗi nằm trong mọi giáo trình về concurrency, nhưng vẫn lọt vì code chạy đúng trong mọi test tuần tự. Và ở cả hai, khối lượng request song song là thứ kẻ xấu chủ động tạo ra; ở LMS, cao điểm cuối hạn tạo ra nó miễn phí.
8.2 Anti-pattern thường gặp khi review
- "Giao diện không cho làm" — ẩn nút, disable field, ẩn bước, rồi coi đó là luật (CWE-602).
- Đọc rồi kiểm rồi ghi, bọc trong transaction và tin là đủ — ở isolation mặc định, transaction không làm phép kiểm thành nguyên tử (mục 3.4 đoạn 4).
- Validation định dạng cho field lẽ ra không được tồn tại — thêm
between:0,10choscoredo client gửi. - Trạng thái luồng lưu ở client — hidden field
step, cookieverified=1. - Chỉ có rate limit theo IP ở gateway cho tính năng phát giá trị — không đếm đại lượng nghiệp vụ, chặn nhầm NAT của cả trường.
- Dùng Redis lock có TTL làm bảo đảm duy nhất cho tính đúng của dữ liệu, không có unique constraint phía sau.
- Thời điểm nghiệp vụ (nộp, đăng ký) ghi lúc worker xử lý thay vì lúc server nhận request.
- Entry point thứ hai quên luật — API mới, import CSV, lệnh artisan, webhook gọi thẳng model thay vì qua service có luật.
- Hai hệ thống chung database, hệ thứ hai ghi thẳng bảng của hệ thứ nhất và đi vòng qua luật của chủ dữ liệu.
- Threat modeling một lần lúc khởi động dự án rồi thôi — cheat sheet: mô hình "should be maintained, updated and refined alongside the system".
- Coi "tool quét sạch" là bằng chứng thiết kế an toàn — cheat sheet: "a clean scanner report does not show that those rules are enforced".
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Insecure design khác insecure implementation thế nào? | Thiết kế thiếu control thì code hoàn hảo cũng không vá được; thiết kế đúng vẫn có thể cài sai. Khác nguyên nhân, khác thời điểm, khác cách sửa — OWASP nói rõ. |
| Làm sao phát hiện lỗi thiết kế khi tool không bắt được? | Threat modeling cho luồng rủi ro, invariant trong story, review hỏi "cái gì vắng mặt", test invariant/thứ tự/đồng thời, và job đếm vi phạm invariant trên production. |
| Threat modeling làm thế nào cho team nhỏ mà không thành thủ tục? | Chỉ cho tính năng chạm tiền/điểm/quyền/PII hoặc thêm kết nối; 45 phút trong refinement; DFD + STRIDE hoặc abuse case; đầu ra là vài security story, không phải tài liệu. |
| STRIDE là gì? | Sáu nhóm mối đe doạ — giả mạo, sửa đổi, chối bỏ, lộ thông tin, từ chối dịch vụ, leo thang quyền — mỗi nhóm vi phạm một thuộc tính bảo mật; dùng làm câu gợi ý khi đi qua từng mũi tên của DFD. |
| Chống race condition khi ghi danh lớp còn 1 chỗ? | UPDATE trừ chỗ có điều kiện "còn > 0", kiểm số dòng bị ảnh hưởng; unique (sinh viên, lớp) làm chốt cuối; cả hai trong một transaction. Luật đếm nhiều dòng thì khoá dòng cha hoặc SERIALIZABLE + retry. |
| Transaction có đủ chống race không? | Không, ở READ COMMITTED / REPEATABLE READ mặc định, đọc rồi ghi trong transaction vẫn xen kẽ được. Cần ghi có điều kiện, khoá dòng, hoặc SERIALIZABLE. |
| Pessimistic hay optimistic lock? | Pessimistic khi xung đột thường xuyên và thời gian giữ khoá ngắn (ghi danh cao điểm). Optimistic khi xung đột hiếm và có người để báo "dữ liệu đã đổi" (hai admin sửa cùng khoá học). |
| Rate limit có giải quyết business logic abuse không? | Một phần: nó chặn tần suất request, không chặn khối lượng nghiệp vụ trong một request hay lạm dụng chậm. Cần thêm trần nghiệp vụ theo lần/tài khoản/tenant. |
| Vì sao câu hỏi bảo mật là ví dụ của A06 chứ không phải A07? | Vì không có cách cài nào làm nó an toàn: nhiều người biết câu trả lời. Sửa bằng cách bỏ tính năng, thay thiết kế — định nghĩa của lỗi thiết kế. |
| Paved road là gì, rủi ro gì? | Thành phần dựng sẵn an toàn mặc định để con đường dễ nhất cũng là an toàn (validation, state machine, idempotency, quota). Rủi ro: cần người bảo trì, lỗi ở đó lan mọi nơi, bất tiện thì dev đi đường tắt. |
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:
- Hành vi khoá của database: câu UPDATE có điều kiện thứ hai chờ rồi đánh giá lại điều kiện trên giá trị mới (InnoDB, PostgreSQL ở
READ COMMITTED);
FOR UPDATEkhông chặn phantom insert ở READ COMMITTED; partial unique index của PostgreSQL — theo docs, chưa chạy ở bài này (lab khoá dòng/version có ở bài Optimistic locking, lab unique index ở bài Chống double-submit). - Laravel:
increment/decrementcủa query builder trả về số dòng bị ảnh hưởng (docs chỉ ghi rõ choupdate);RefreshDatabaselàm test đồng thời trong cùng kết nối vô nghĩa; SQLite không có cùng ngữ nghĩa khoá — theo hiểu biết về mã nguồn/cách test của Laravel, chưa verify. - TypeORM / MySQL:
affectedvới driver mysql2 đếm dòng đổi giá trị hay dòng khớp; cú phápset({ used: () => 'used + :n' })kết hợpsetParameter— chưa chạy. - NestJS throttler: cách kết hợp nhiều throttler set (
skipIftheo URL như mẫu là minh hoạ; cách khuyến nghị là@Throttletheo tên set trên handler) — docs đọc qua Context7, chưa chạy. Custom decorator@CurrentVerifiedStudent()là mẫu giả định. - Moodle: các cài đặt hạn nộp/giới hạn thời gian/xử lý khi hết giờ của quiz; cách Moodle Web Services kiểm capability; những gì Moodle làm khi ghi qua API (event, tính lại điểm tổng, xoá cache) — theo hiểu biết chung về Moodle, chưa đọc mã nguồn ở bài này.
- Lý do OWASP bỏ dòng "Limit resource consumption" khỏi How to prevent năm 2025, và vì sao avg weighted impact giảm từ 6,78 xuống 4,05 — OWASP không nêu.
- Sự cố ở mục 8.1: mô tả theo thông báo của Flexcoin qua Ars Technica / Computerworld, blog của Egor Homakov và Ars Technica, các mô tả công khai về vụ Yahoo/Palin; con số (896 BTC, ~600.000 USD, ~10 ngày) không tự kiểm.
- NIST SP 800-63B cấm câu hỏi bảo mật: theo lời OWASP trong kịch bản số 1, chưa đọc bản NIST hiện hành.
- Các giả định * ở mục 7: portal NestJS ghi hay chỉ đọc bảng Moodle; worker có ghi thời điểm nộp không; quy trình spec của team có mục luồng lỗi/invariant không — CV không nêu.
Nguồn
- OWASP Top 10:2025 — A06 Insecure Design: top10.owasp.org (Background, bảng điểm, Description, How to prevent, 3 kịch bản, 39 CWE); bản markdown: github.com/OWASP/Top10; A04:2021 Insecure Design (số liệu và danh sách CWE để so sánh): github.com/OWASP/Top10
- OWASP Cheat Sheets: Threat Modeling (4 câu hỏi, DFD, STRIDE, chọn kỹ thuật, 4 phản ứng), Business Logic Security (tính lại ở server, state machine, race condition, idempotency, abuse-friendly features, invariant, test, checklist), Secure Product Design (secure defaults, paved road, connections), Abuse Case (Historical) (archive statement)
- OWASP ASVS 5.0: V2 Validation and Business Logic (2.1.3, 2.2.2, 2.3.1–2.3.5, 2.4.1–2.4.2), V15 Secure Coding and Architecture (15.1.3, 15.2.3, 15.3.1, 15.4.2)
- OWASP SAMM: Design — Threat Assessment, Design — Secure Architecture; Threat Modeling Manifesto
- Laravel 13 docs: Database — transactions, handling deadlocks, Query Builder — update, increment/decrement, pessimistic locking, Cache — atomic locks, Rate Limiting — tra qua Context7
/laravel/docs - NestJS docs: Validation (
whitelist,forbidNonWhitelisted), Rate limiting (@Throttle,getTracker,skipIf) — tra qua Context7/nestjs/docs.nestjs.com - TypeORM: Transactions, Select Query Builder — lock modes,
UpdateResult.affected— tra qua Context7/typeorm/typeorm - Sự cố: Ars Technica — How the "world's first Bitcoin bank" was robbed blind; Computerworld — Withdrawal vulnerabilities enabled bitcoin theft from Flexcoin and Poloniex; Egor Homakov — Hacking Starbucks for unlimited coffee; Ars Technica — Researcher who exploits bug in Starbucks gift cards gets rebuke; Wikipedia — Sarah Palin email hack
- Trong repo: Bài 0 — tổng quan, Bài 1 — A01, Bài 2 — A02, Bài 3 — A03, Bài 4 — A04, Bài 5 — A05, Chống double-submit, Idempotency key ở Redis, Optimistic locking, Gửi mail at-least-once / exactly-once, Gửi mail cho 1 triệu user, Scale out queue worker, Codebase dùng chung, Livewire public property, Phạm vi rate limit, 6 thuật toán rate limit, ABAC, RBAC,
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 5 — A05 Injection · Bài 6 — A06 Insecure Design (bài này) · Tiếp: Bài 7 — A07 Authentication Failures →