Bài 1 — A01:2025 Broken Access Control: phân quyền theo object, theo chức năng, CSRF và SSRF
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · Bài 1 — A01 Broken Access Control (bài này) · Tiếp: Bài 2 — A02 Security Misconfiguration →
Broken Access Control (kiểm soát truy cập bị hỏng) là mục đứng đầu OWASP Top 10 cả năm 2021 lẫn 2025, và là mục mà
interviewer cho Senior Backend hỏi nhiều nhất theo kiểu "anh kiểm tra quyền ở đâu trong code". Câu trả lời
"có middleware auth" là câu trả lời của junior: middleware đó chỉ biết ai đang gọi, không biết người đó có được
động vào bản ghi này, chức năng này, field này, hay được bắt server gọi ra địa chỉ này không. Bài này đi qua
từng dạng hỏng của A01:2025 — kể cả SSRF, vốn là mục riêng năm 2021 và nay được gộp vào — theo hướng phòng thủ: giả
định nào trong code bị sai, hệ quả là gì, viết lại thế nào cho đúng trong Laravel và NestJS, và mỗi lớp phòng thủ
không che được gì.
Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP, docs framework) 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. Một số hành vi của thư viện được đọc thẳng từ source code (ghi rõ file, nhánh) — đọc source vẫn không phải là chạy thật.
Trả lời 30 giây. "A01 là mọi trường hợp người dùng làm được việc ngoài quyền của mình: đọc hoặc sửa bản ghi của người khác bằng cách đổi ID, gọi được chức năng admin, tự sửa field đặc quyền, đọc file ngoài thư mục cho phép, để trang web khác mượn cookie của user (CSRF) hoặc mượn vị trí mạng của server (SSRF). Gốc rễ chung là quyết định phân quyền bị đặt sai chỗ hoặc bị quên ở một đường đi nào đó. Cách tôi làm: deny by default ở tầng route, kiểm tra quyền theo từng object bằng policy hoặc lọc ngay trong query, whitelist field ở DTO, chỉ gọi ra ngoài qua allowlist, và có test âm cho từng cặp user–object."
1. Mục này là gì trong 2025
1.1 Định nghĩa của OWASP và tám dạng hỏng điển hình
1. OWASP định nghĩa: access control "enforces policy such that users cannot act outside of their intended permissions" — kiểm soát truy cập thực thi chính sách để người dùng không hành động ra ngoài quyền được cấp. Hỏng nó dẫn tới lộ dữ liệu trái phép, sửa hoặc xoá dữ liệu, hoặc thực hiện một chức năng nghiệp vụ vượt giới hạn của người dùng. Cần phân biệt ngay với authentication (xác thực — trả lời "anh là ai"): authorization (phân quyền — trả lời "anh được làm gì với cái gì") chỉ bắt đầu sau khi đã biết danh tính. Authorization Cheat Sheet nhấn mạnh một người đã đăng nhập thường vẫn không được phép làm mọi việc mà hệ thống về mặt kỹ thuật làm được.
2. Trang A01:2025 liệt kê các dạng hỏng phổ biến (tóm lược, không dịch nguyên văn):
| # | Dạng hỏng theo OWASP | Giả định sai trong code | Mục trong bài |
|---|---|---|---|
| 1 | Vi phạm least privilege / không deny by default — quyền lẽ ra chỉ cấp cho vài vai trò lại mở cho mọi người | "Route mới thì chắc ai đó sẽ nhớ gắn middleware" | 3.2, 4.2 |
| 2 | Vượt kiểm tra bằng cách sửa URL (parameter tampering, force browsing), sửa state nội bộ, sửa HTML, hoặc sửa request API | "Client chỉ gửi những gì giao diện cho phép" | 3.1–3.3 |
| 3 | Xem/sửa tài khoản người khác bằng cách đưa định danh của nó — IDOR (insecure direct object reference) | "ID trong URL là của chính người đang gọi" | 3.1, 4.1 |
| 4 | API thiếu kiểm soát cho POST, PUT, DELETE | "Đã check ở GET rồi" | 3.2, 4.2 |
| 5 | Leo thang đặc quyền: hành động như user khi chưa đăng nhập, hoặc có quyền admin khi chỉ là user thường | "Field role không có trên form thì không ai gửi" | 3.3, 4.3 |
| 6 | Thao túng metadata: replay/sửa JWT, cookie hoặc hidden field để nâng quyền, lạm dụng việc JWT không thu hồi được | "Claim trong token / giá trị trong cookie là đáng tin" | 3.3 |
| 7 | CORS cấu hình sai cho phép origin không tin cậy truy cập API | "CORS là để chặn" (thật ra CORS là để nới) | 3.5, 4.5 |
| 8 | Force browsing tới trang cần đăng nhập khi chưa đăng nhập, hoặc trang đặc quyền khi là user thường | "Không có link trên menu thì không ai biết URL" | 3.2 |
3. Ba kịch bản ví dụ OWASP đưa ra trên trang A01 đều rất ngắn, và cả ba đều là cùng một lỗi tư duy: (1) ứng dụng lấy
tham số tài khoản acct từ request và đưa thẳng vào câu SQL — người dùng đổi tham số là xem được tài khoản bất kỳ;
(2) trang admin chỉ được "giấu" bằng URL — ai gõ đúng URL là vào được, và OWASP nói rõ: chưa đăng nhập mà vào được trang
nào trong hai trang là lỗi, user thường vào được trang admin cũng là lỗi; (3) toàn bộ kiểm soát nằm ở JavaScript phía
trình duyệt — người ta không bấm được nút, nhưng gọi thẳng URL bằng một HTTP client bất kỳ thì vẫn được. Câu chốt của
phần "How to prevent": access control "is only effective when implemented in trusted server-side code or serverless
APIs, where the attacker cannot modify the access control check or metadata".
1.2 Thay đổi so với 2021
1. Giữ hạng 1. OWASP viết: "100% of the applications tested were found to have some form of broken access control". Đây là nhóm có nhiều occurrence nhất trong dữ liệu đóng góp và đứng thứ hai về số CVE liên quan.
2. Gộp SSRF. A10:2021 Server-Side Request Forgery không còn là mục riêng; CWE-918 nằm trong danh sách CWE của A01:2025 (chi tiết đối chiếu ở Bài 0, mục 4.1). Trang A01 nêu đích danh bốn CWE "đáng chú ý": CWE-200, CWE-201, CWE-918 (SSRF) và CWE-352 (CSRF). Cách tôi hiểu vì sao SSRF thuộc về đây (OWASP không viết câu này): SSRF là server dùng quyền mạng của chính nó để truy cập thứ mà người gọi không được phép chạm tới — vẫn là "hành động ngoài quyền", chỉ là ở chiều request đi ra thay vì đi vào.
3. Số CWE tăng lên 40 (2021 là 34, theo bảng đối chiếu ở Bài 0) — chạm đúng mức trần 40 CWE/category mà OWASP
tự đặt cho bản 2025. Ngoài CWE-918, danh sách có CWE-1275 (cookie với thuộc tính SameSite không phù hợp) — tức là
OWASP coi cấu hình cookie phục vụ chống CSRF cũng là một phần của access control.
1.3 Bảng điểm chính thức
| CWEs Mapped | Max Incidence | Avg Incidence | Max Coverage | Avg Coverage | Avg Weighted Exploit | Avg Weighted Impact | Total Occurrences | Total CVEs |
|---|---|---|---|---|---|---|---|---|
| 40 | 20,15% | 3,74% | 100,00% | 42,93% | 7,04 | 3,84 | 1.839.701 | 32.654 |
Đọc bảng này khi phỏng vấn: incidence là phần trăm ứng dụng được test có ít nhất một CWE của nhóm (không phải số lỗi) — trung bình 3,74%, CWE cao nhất 20,15%; coverage là phần trăm ứng dụng được test cho CWE đó. Exploit 7,04 cao trong khi Impact 3,84 thấp hơn nhiều: lỗi A01 thường dễ khai thác (đổi một con số, gọi một URL) còn mức thiệt hại phụ thuộc vào dữ liệu đằng sau. Cách tính và ý nghĩa từng cột đã giải thích ở Bài 0, mục 2, ở đây không lặp lại.
1.4 Gom 40 CWE theo cơ chế
Danh sách 40 CWE trên trang OWASP là một danh sách phẳng. Gom theo "cái gì bị kiểm soát sai" thì nhớ dễ hơn — cách gom dưới đây là của bài này, không phải của OWASP:
| Nhóm (cách gom của bài) | CWE | Số CWE |
|---|---|---|
| Thiếu hoặc sai kiểm tra quyền trên chức năng/object | 284, 285, 862 (Missing Authorization), 863 (Incorrect Authorization), 639 (bypass bằng key do user điều khiển), 566 (bypass bằng SQL primary key), 425 (forced browsing), 424 (đường đi thay thế không được bảo vệ), 749 (lộ method nguy hiểm), 282, 283 (quản lý/xác minh quyền sở hữu) | 11 |
| Lộ thông tin cho người không có quyền | 200, 201, 359 (thông tin cá nhân), 497, 538, 540, 548 (directory listing), 615 (comment trong source), 219 (file nhạy cảm dưới web root), 552, 668, 402, 922 | 13 |
| Đường dẫn file và quyền file | 22, 23, 36 (path traversal), 59, 61, 65 (link following), 276, 281, 732 (quyền mặc định/gán quyền sai), 377, 379 (file tạm) | 11 |
| Request "mượn danh" — hệ thống hành động bằng thẩm quyền của mình thay cho người khác | 352 (CSRF), 1275 (SameSite), 918 (SSRF), 441 (confused deputy), 601 (open redirect) | 5 |
Nhóm cuối là lý do bài này đặt CSRF và SSRF cạnh IDOR: cả ba đều là confused deputy (CWE-441 — "người được uỷ quyền bị lừa"): một thành phần có thẩm quyền (trình duyệt mang cookie của user, server đứng trong mạng nội bộ) bị một bên không có thẩm quyền sai khiến làm việc thay.
2. Bài toán gốc — vì sao lỗi này không bao giờ hết
1. Quyết định phân quyền có bốn chiều, còn code được tổ chức theo một chiều. Một quyết định phân quyền đầy đủ là hàm của chủ thể (ai), hành động (làm gì), đối tượng (với bản ghi nào) và ngữ cảnh (tenant nào, lúc nào, trạng thái bản ghi ra sao). Nhưng code được chia theo endpoint/controller. Mỗi endpoint mới là một lần phải nhớ tái tạo đủ bốn chiều đó. Authorization Cheat Sheet nói thẳng: "an attacker only needs to find one way in" — kiểm tra đúng ở phần lớn request là chưa đủ.
2. Framework cho sẵn authentication, không cho sẵn quyền theo bản ghi. Middleware auth, guard JWT,
spatie/laravel-permission đều trả lời được "user này có role/permission X không" — câu hỏi về loại tài nguyên.
Không cái nào tự biết bài nộp số 42 là của ai. Đây chính là Bẫy 1 trong bài
RBAC: RBAC không biết "bản ghi nào", nên chỉ có RBAC là có IDOR. Câu hỏi "bản ghi nào" thuộc về
policy theo thuộc tính/quan hệ — xem ABAC; Authorization Cheat Sheet cũng khuyên ưu tiên ABAC/ReBAC
hơn RBAC thuần cho ứng dụng vì lý do này.
3. Tin vào thứ client gửi lên. ID trong URL, hidden field user_id, field role trong JSON body, claim
trong JWT không được kiểm chữ ký, public property của Livewire — tất cả đều là dữ liệu do client điều khiển. Ranh giới tin
cậy (trust boundary) nằm ở server; mọi thứ đi qua ranh giới đó phải được coi là đầu vào cần kiểm tra, kể cả khi giao diện
"không cho sửa". Bài Public property của Livewire là
client-writable là một ví dụ cụ thể của đúng nguyên tắc này.
4. Một dữ liệu, nhiều đường đi. Cùng một bảng điểm có thể đi ra qua: API REST, trang HTML, file export Excel, GraphQL resolver, job gửi email, cache, file tĩnh trên storage. Check được đặt ở một đường, quên ở đường khác — đó là CWE-424 (đường đi thay thế không được bảo vệ). Vụ Optus 2022 (mục 8) theo hồ sơ của cơ quan quản lý Úc là đúng kiểu này: lỗi access control đã được sửa trên site chính nhưng không được sửa trên một API endpoint ở subdomain.
5. Thẩm quyền "mặc nhiên" (ambient authority). Trình duyệt tự gắn cookie vào mọi request tới domain của bạn, bất kể trang nào khởi tạo request — đó là nền của CSRF. Server đứng trong mạng nội bộ, có route tới database, tới dịch vụ metadata của cloud, tới admin panel nội bộ — đó là nền của SSRF. Hai lỗi này không cần "đổi ID" nào cả: kẻ tấn công mượn thẩm quyền sẵn có của một bên khác.
6. Test thường chỉ có ca vui. Test kiểm tra "An xem được bài của An" mà hiếm khi kiểm tra "An không xem được bài của Bình". OWASP (cả trang A01 lẫn IDOR Cheat Sheet) yêu cầu đưa functional access control vào unit/integration test và test bằng ít nhất hai tài khoản có phạm vi khác nhau.
7. Ví dụ minh hoạ bằng số (số giả định, để thấy vì sao "nhớ gắn check" không phải là một chiến lược). Một portal có 40 endpoint nhận ID từ request. Giả sử mỗi lần viết endpoint, xác suất developer quên check quyền là 2%. Xác suất để ít nhất một endpoint thủng:
P(ít nhất 1 lỗ) = 1 − (1 − 0,02)^40
= 1 − 0,98^40
= 1 − e^(40 × ln 0,98)
= 1 − e^(−0,808)
≈ 1 − 0,446
≈ 0,55 → khoảng 55%
Cách nói khi phỏng vấn: "Giả sử mỗi endpoint chỉ có hai phần trăm khả năng bị quên check quyền, thì với bốn mươi endpoint, khả năng có ít nhất một chỗ thủng đã hơn một nửa. Kẻ tấn công chỉ cần một chỗ. Vì vậy tôi không dựa vào việc developer nhớ; tôi đổi mặc định: route nào không khai báo quyền thì bị đóng, và quyền theo bản ghi nằm trong query hoặc policy dùng chung. Khi quên, hệ thống hỏng theo kiểu đóng cửa chứ không phải mở cửa."
8. Hệ quả cho thiết kế: muốn giảm A01 thì phải đổi mặc định (deny by default, lọc theo owner trong query), tập trung logic (policy dùng chung cho mọi đường đi), và tự động hoá kiểm chứng (test âm, test kiến trúc). Mục 4–6 đi lần lượt ba hướng này.
3. Cơ chế — mỗi dạng hỏng sai ở giả định nào
Phần này mô tả cơ chế ở mức khái niệm: code đang giả định điều gì, giả định đó sai ở đâu, hệ quả là gì. Không có chuỗi tấn công hay thủ thuật vượt rào — mục tiêu là nhận ra mẫu sai khi review code. Ví dụ dùng bối cảnh LMS (sinh viên An, sinh viên Bình, giảng viên Cường) cho gần với hệ thống trong CV; các ID là minh hoạ.
3.1 Phân quyền theo object hỏng — IDOR / BOLA
1. IDOR Cheat Sheet tách lỗi này thành ba thành phần: một object (tài khoản, tài liệu, bài nộp, hoá đơn), một tham chiếu tới object đó (ID, UUID, số tài khoản, slug, tên file), và việc thiếu kiểm tra phân quyền ở mức object. Trong OWASP API Security Top 10 nó có tên BOLA (Broken Object Level Authorization) và đứng hạng 1.
2. Giả định sai: "người gửi ID này là chủ của object mang ID này". Endpoint GET /submissions/41 trả về bài nộp
41 cho bất kỳ ai đã đăng nhập. An xem bài của mình là 41; giá trị 42 trong URL hoàn toàn do An điều khiển, và server không hỏi
"42 có phải của An không". Đây là leo quyền ngang (horizontal privilege escalation — cùng mức quyền, khác chủ sở hữu); nếu
object là của một vai trò cao hơn thì thành leo quyền dọc.
3. Tham chiếu không chỉ là số trong URL. IDOR Cheat Sheet đưa hai ví dụ khác: user_id trong hidden field của form cập
nhật profile, và tên file làm tham chiếu (/documents/<tên file>.pdf). Bất cứ giá trị nào đi từ client để
chọn ra bản ghi đều là tham chiếu.
4. UUID không phải là bản vá. Cheat Sheet nói rõ: ID phức tạp khiến việc đoán gần như không thể, nhưng "even with complex identifiers, access control checks are essential" — ID vẫn lọt ra qua link chia sẻ, log, email, lịch sử trình duyệt, header Referer. UUID là lớp phòng thủ chiều sâu, không thay thế kiểm tra quyền. Cheat Sheet còn khuyên không mã hoá ID như một cách che (khó làm đúng).
5. Hệ quả nhân bản theo mọi thao tác: đọc, sửa, xoá, export, thao tác admin — Cheat Sheet yêu cầu kiểm chứng cho tất cả. Endpoint export thường bị quên nhất vì nó "chỉ là tải file".
flowchart TD R["An gửi GET /submissions/42"] --> A["auth: An đã đăng nhập ✓"] A --> Q["SELECT * FROM submissions<br/>WHERE id = 42"] Q --> L["Trả bài của Bình cho An"] Q -.->|"thiếu câu hỏi"| M["42 có thuộc An không?"]
3.2 Phân quyền theo chức năng hỏng — force browsing, API thiếu check
1. Giả định sai: "không có link/nút thì không ai gọi được". Kịch bản 2 và 3 của OWASP: trang admin chỉ được giấu bằng URL, hoặc chỉ được chặn bằng JavaScript phía trình duyệt. Giao diện là một client; HTTP API chấp nhận mọi client.
2. Biến thể phổ biến trong API: check có ở GET nhưng không có ở PUT/DELETE cùng resource (OWASP gọi
riêng: "missing access controls for POST, PUT, and DELETE"); route admin cũ còn sống sau khi giao diện đã bỏ; endpoint nội bộ
(health, debug, metrics) mở ra Internet. Vụ Optus là một API "dormant and not in use" nhưng vẫn mở ra Internet (mục 8).
3. Gốc của lỗi là mặc định mở: route mới sinh ra ở trạng thái "ai cũng gọi được", và chỉ đóng lại nếu có người nhớ đóng. Authorization Cheat Sheet: ứng dụng "must always make a decision" — khi không luật nào khớp, quyết định phải là từ chối; và nên cấu hình tường minh thay vì dựa vào mặc định của framework (mặc định có thể đổi theo version).
flowchart TD
N["Developer thêm route mới"] --> D{"Mặc định của hệ thống?"}
D -->|"mở"| O["Route chạy cho mọi user<br/>cho tới khi có người nhớ gắn check"]
D -->|"đóng (deny by default)"| C["Route trả 401/403<br/>cho tới khi khai báo quyền"]
O --> X["Quên = lỗ hổng"]
C --> Y["Quên = bug chức năng, QA phát hiện"]
3.3 Sửa state và metadata — field đặc quyền, token, cookie
1. Giả định sai: "request body chỉ chứa những field mà form có". Endpoint cập nhật profile nhận toàn bộ JSON body và đổ
vào model. Nếu model cho phép ghi cột role, is_admin, tenant_id, student_id thì người dùng tự
nâng quyền hoặc tự chuyển bản ghi sang tenant khác. Docs Eloquent mô tả đúng kịch bản này: người dùng gửi thêm tham số
is_admin và được đưa thẳng vào create. Về mặt phân loại, mass assignment (CWE-915) nằm ở A08:2025 — xem
Bài 8; ở A01 ta quan tâm hệ quả của nó: leo quyền.
2. Giả định sai thứ hai: "giá trị trong token/cookie là sự thật". OWASP liệt kê việc replay hoặc sửa JWT, cookie,
hidden field để nâng quyền, và lạm dụng việc JWT không thu hồi được. Claim role trong JWT chỉ đáng tin nếu chữ ký được
kiểm (chủ đề của Bài 7 và
Bài 4) và nếu quyền không thay đổi trong thời gian token còn sống. Phần
"How to prevent" của A01 yêu cầu: session phía server phải bị huỷ khi logout; JWT stateless nên sống ngắn; JWT sống lâu thì
dùng refresh token và cơ chế thu hồi theo OAuth.
3. Ví dụ minh hoạ về thời gian sống: giảng viên bị gỡ quyền chấm bài lúc 9:00, access token chứa claim grader có TTL 60
phút được cấp lúc 8:50 → còn tới 9:50 người đó vẫn chấm được nếu hệ thống chỉ đọc claim. TTL 15 phút thu hẹp cửa sổ xuống
tối đa 15 phút; muốn bằng 0 thì quyết định phân quyền phải đọc trạng thái hiện tại (DB/cache có invalidation) thay vì claim.
Bài RBAC (Bẫy 2: gỡ quyền rồi mà quyền vẫn còn — 3 lớp cache) đi sâu chỗ này.
flowchart TD B["Body: name, avatar_url,<br/>+ role = admin"] --> F["fill(toàn bộ body)"] F --> M["Model cho ghi mọi cột"] M --> E["User tự nâng role"] B --> W["Whitelist field theo use case"] W --> S["Chỉ ghi name, avatar_url"]
3.4 Đường dẫn file và tài nguyên tĩnh
1. Giả định sai: "tên file do user gửi chỉ là một tên file". Khi server ghép chuỗi thư_mục_gốc + tên_từ_request
rồi mở file, các thành phần đặc biệt của đường dẫn (thư mục cha, đường dẫn tuyệt đối, symlink) có thể đưa đường dẫn cuối cùng
ra ngoài thư mục gốc — nhóm CWE-22/23/36 (path traversal) và CWE-59/61 (đi theo link). Đây là lỗi escape khỏi thư mục.
2. Có một lỗi thứ hai hay bị nhầm là đã được giải quyết bởi lỗi thứ nhất: không escape khỏi thư mục nhưng vẫn đọc được
file của người khác bên trong thư mục. Thư mục reports/ chứa báo cáo của mọi giảng viên; chặn path traversal
không ngăn được việc đổi tên file sang báo cáo của người khác — đó là IDOR với tên file làm tham chiếu (3.1).
3. Tài nguyên tĩnh và storage: Authorization Cheat Sheet có hẳn mục "Enforce Authorization Checks on Static
Resources", nhắc riêng bucket cloud (S3) cấu hình sai. Trang A01 yêu cầu tắt directory listing và không để metadata
(.git) hay file backup trong web root. Với hệ thống trong CV: moodledata trên NFS, MinIO, S3 đều là "static resource"
theo nghĩa này (mục 7).
flowchart TD
I["Tên file từ request"] --> J["Ghép vào thư mục gốc"]
J --> K{"Đường dẫn thật<br/>nằm trong thư mục gốc?"}
K -->|"không"| P["Lỗi 1: path traversal<br/>(CWE-22, 59)"]
K -->|"có"| Q2{"File thuộc người đang gọi?"}
Q2 -->|"không"| R2["Lỗi 2: IDOR qua tên file"]
Q2 -->|"có"| OK["Được phép"]
3.5 CORS cấu hình sai
1. Hiểu đúng trước: Same-Origin Policy của trình duyệt mặc định không cho JavaScript của trang A đọc response từ origin B. CORS (Cross-Origin Resource Sharing) là cơ chế để server B nới quy tắc đó cho những origin nó chọn. CORS không chặn request đến server — nó quyết định trình duyệt có đưa response cho script của trang khác đọc hay không.
2. Giả định sai: "bật CORS cho mọi origin cũng không sao, đằng nào cũng cần đăng nhập". Nếu API dùng cookie để xác thực
và trả Access-Control-Allow-Credentials: true kèm Access-Control-Allow-Origin lặp lại (reflect) bất kỳ origin nào
gửi tới, thì script trên một trang bất kỳ mà người dùng đang đăng nhập ghé qua có thể đọc dữ liệu của họ từ API. Trình duyệt
từ chối tổ hợp Allow-Origin: * với credentials (CSRF Cheat Sheet), nhưng "reflect origin" thì không bị từ chối — và một
số thư viện tự chuyển * thành reflect khi bật credentials (mục 4.5 có source cụ thể).
3. Biến thể: allowlist bằng regex không neo đầu/cuối hoặc chấp nhận mọi subdomain. CSRF Cheat Sheet cảnh báo: cho phép mọi subdomain bằng regex là cấu hình kém an toàn vì subdomain có thể bị chiếm (subdomain takeover, thường gặp với dịch vụ cloud). OWASP prevention: "minimizing Cross-Origin Resource Sharing (CORS) usage".
sequenceDiagram participant P as Trang web khác participant B as Trình duyệt (user đang đăng nhập) participant S as API dùng cookie P->>B: script gọi API, kèm credentials B->>S: request + cookie + Origin của trang khác S-->>B: Allow-Origin lặp lại Origin vừa nhận, Allow-Credentials true B-->>P: trình duyệt cho script đọc response Note over S: Lỗi nằm ở việc server tin mọi Origin
3.6 CSRF — trình duyệt bị mượn để gửi request
1. CSRF Cheat Sheet: một trang web, email hay chương trình độc hại khiến trình duyệt của người dùng đã đăng nhập thực hiện một hành động không mong muốn trên site tin cậy. Vì trình duyệt tự đính kèm cookie, site không có phòng thủ "cannot distinguish between legitimate authorized requests and forged authenticated requests".
2. Giả định sai: "có cookie session hợp lệ nghĩa là chính người dùng muốn làm việc này". Cookie chứng minh danh tính của trình duyệt, không chứng minh ý định hay nguồn gốc của request. Phạm vi thiệt hại bằng đúng quyền của nạn nhân: đổi email, đổi mật khẩu, nộp bài, sửa điểm nếu nạn nhân là giảng viên.
3. Ba điều hay bị bỏ sót: (a) GET đổi trạng thái — mọi cơ chế chống CSRF hiện đại (SameSite=Lax, Fetch Metadata trong Laravel 13 và NestJS) đều để GET đi qua, nên GET có side effect là thủng; (b) login CSRF — form đăng nhập cũng cần chống, vì ép nạn nhân đăng nhập vào tài khoản của kẻ khác cũng gây hại (Cheat Sheet có mục riêng); (c) XSS phá mọi phòng thủ CSRF — Cheat Sheet in đậm câu này; script chạy trong origin của bạn là same-origin.
flowchart TD
V["User đang đăng nhập<br/>lms.example.edu.vn"] --> X["Mở một trang khác"]
X --> F2["Trang đó khiến trình duyệt<br/>gửi POST tới LMS"]
F2 --> C2["Trình duyệt tự gắn cookie session"]
C2 --> S2{"Server chỉ kiểm cookie?"}
S2 -->|"có"| H["Hành động chạy dưới tên user"]
S2 -->|"kiểm thêm nguồn gốc / token"| D2["Từ chối 403/419"]
3.7 SSRF — server bị mượn để gửi request
1. SSRF Cheat Sheet mô tả SSRF là lạm dụng ứng dụng để tương tác với mạng nội bộ/bên ngoài hoặc chính máy chủ, và nêu ba tính năng điển hình tạo ra rủi ro: tải ảnh từ URL người dùng nhập (avatar), webhook/callback URL do người dùng cấu hình, và request nội bộ giữa các service mang theo dữ liệu người dùng.
2. Giả định sai: "URL người dùng đưa thì cứ fetch, server mình chỉ là người đưa thư". Server không phải người đưa thư trung lập: nó đứng sau firewall, có route tới database, Redis, admin panel nội bộ, và — trên cloud — tới dịch vụ metadata cấp credential tạm thời cho máy. Cheat Sheet nhấn mạnh dịch vụ metadata là đích thường gặp; AWS IMDSv2 là lớp phòng thủ chiều sâu được khuyên dùng. Vụ Capital One 2019 (mục 8) là ví dụ công khai nổi tiếng nhất.
3. Vì sao lọc URL ở tầng ứng dụng khó (theo Cheat Sheet, chỉ nêu khái niệm): (a) parser khác nhau có thể đọc ra host khác nhau từ cùng một chuỗi URL — Cheat Sheet khuyên coi bất đồng giữa các parser là lý do từ chối; (b) DNS — tên miền có thể trỏ tới IP nội bộ, và kết quả phân giải lúc kiểm tra có thể khác lúc kết nối (DNS rebinding), nên phải kiểm IP tại thời điểm kết nối; (c) redirect — URL hợp lệ có thể chuyển hướng sang đích không hợp lệ, nên phải tắt follow redirect; (d) scheme khác HTTP — Cheat Sheet lưu ý SSRF không giới hạn ở HTTP.
4. Cheat Sheet chia hai trường hợp, và đây là câu hỏi đầu tiên phải trả lời khi thiết kế: Case 1 — ứng dụng chỉ cần gọi tới một tập đích đã biết → dùng allowlist, chặt và đơn giản; Case 2 — ứng dụng phải gọi tới đích bất kỳ trên Internet (webhook của khách hàng) → không có allowlist, phải chặn dải địa chỉ nội bộ sau khi phân giải DNS, và phải có lớp mạng (egress) đỡ phía sau vì deny-list ở tầng ứng dụng "bypass-prone".
flowchart LR U["User nhập avatar_url"] --> A3["App server fetch URL"] A3 --> E3["Đích ngoài Internet<br/>(mục đích thật)"] A3 -.->|"nếu không kiểm soát"| I3["Dịch vụ nội bộ:<br/>DB, Redis, admin, metadata cloud"] F3["Firewall chặn người ngoài"] -.-> I3 Note3["App server đứng TRONG firewall"] -.-> A3
3.8 Hình động: một request đi qua năm lớp kiểm soát
Gom các cơ chế ở trên thành một luồng phòng thủ: mỗi request phải qua năm câu hỏi theo thứ tự — ai đang gọi, có được gọi chức năng này, object này có thuộc phạm vi của người gọi, được ghi/đọc những field nào, và nếu handler phải gọi ra ngoài thì được gọi tới đâu. Hình dưới cho ba request của An đi qua năm lớp đó.
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ỹ), và một diagram cho luồng phòng thủ. API
của framework lấy theo docs Laravel 13.x và docs NestJS hiện hành (đã tải về, liệt kê ở Nguồn); một số hành vi đọc thẳng từ
source (ghi rõ). Tên quan hệ như teachingCourses() là minh hoạ cho bối cảnh LMS, không phải API của framework.
4.1 Object-level: ownership trong query, policy cho từng bản ghi
// ===== SAI: chỉ biết "đã đăng nhập", không biết "bài của ai" =====
Route::middleware('auth:sanctum')->get('/submissions/{id}', function (string $id) {
return Submission::findOrFail($id);
});
// ===== ĐÚNG (1): danh sách — điều kiện sở hữu nằm ngay trong query =====
public function index(Request $request)
{
return $request->user()->submissions()->latest()->paginate(20);
}
// ===== ĐÚNG (2): một bản ghi — Policy, gắn ở route =====
// app/Policies/SubmissionPolicy.php (policy discovery tự nối với model Submission)
use Illuminate\Auth\Access\Response;
class SubmissionPolicy
{
public function view(User $user, Submission $submission): Response
{
if ($submission->student_id === $user->id) {
return Response::allow(); // sinh viên: bài của chính mình
}
if ($user->teachingCourses()->whereKey($submission->course_id)->exists()) {
return Response::allow(); // giảng viên: khoá mình dạy (minh hoạ)
}
return Response::denyAsNotFound(); // 404 thay vì 403 — xem 4.8
}
}
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/submissions/{submission}', [SubmissionController::class, 'show'])
->can('view', 'submission');
// Route lồng: scopeBindings bắt {submission} phải là con của {course}
Route::get('/courses/{course}/submissions/{submission}', [CourseSubmissionController::class, 'show'])
->scopeBindings()
->can('view', 'submission');
});
Cách nói khi phỏng vấn: "Với danh sách, tôi không lấy hết rồi lọc, mà bắt đầu query từ quan hệ của user hiện tại, nên bản ghi của người khác không bao giờ được đọc lên. Với một bản ghi theo ID, tôi gắn policy ngay ở route: framework nạp bản ghi theo ID, rồi hỏi policy trước khi controller chạy. Policy trả 404 cho bản ghi ngoài phạm vi để không xác nhận là nó tồn tại. Còn scoped binding chỉ đảm bảo bài nộp thuộc đúng khoá học trên URL, không đảm bảo người gọi được xem khoá học đó, nên vẫn cần policy."
Giải thích mẫu đúng:
1. Danh sách: điều kiện sở hữu là một phần của câu query. $request->user()->submissions() sinh ra
WHERE student_id = ? với giá trị lấy từ session/token phía server, không từ request. Đây đúng là khuyến nghị của IDOR
Cheat Sheet (ví dụ Rails @current_user.projects.find(params[:id])): "use datasets that users have access to". Lợi
thế phụ: không có N+1 kiểu "load 100 dòng rồi gọi policy 100 lần", và phân trang/đếm tổng đúng ngay từ đầu. Bài
ABAC (mục 1.4 và Bẫy 1 "policy và danh sách lệch nhau") đi sâu chuyện giữ cho luật của danh sách và
luật của policy không lệch nhau.
2. Một bản ghi: policy ở route. Docs Laravel (Authorization → Via Middleware): middleware
Illuminate\Auth\Middleware\Authorize, gắn bằng ->can('view', 'submission'), nhận model đã được implicit binding
nạp và trả 403 nếu policy từ chối. Đặt ở route thay vì gọi Gate::authorize() trong thân controller có một lợi thế cụ
thể: một lần sửa controller sau này không thể vô tình xoá mất dòng check. Nhược điểm: binding đã chạy một query trước khi check
— chấp nhận được với đọc theo khoá chính.
3. Response::denyAsNotFound() (docs: Policy Responses → Customizing the HTTP Response Status) làm
cho bản ghi ngoài phạm vi trông giống hệt bản ghi không tồn tại. Đổi lại, debug khó hơn — thảo luận ở 4.8.
4. scopeBindings() giải quyết bài toán khác. Docs Routing: khi bind nhiều model lồng nhau, scoped
binding truy vấn model con qua quan hệ của model cha (giả định Course có quan hệ submissions). Nó chặn kiểu
"khoá học 7 nhưng bài nộp của khoá 9", nhưng hoàn toàn không biết người gọi có quyền với khoá 7 hay không. Hai thứ bổ sung cho
nhau; scoped binding một mình không phải là phân quyền.
5. Bẫy của before(). Docs: Gate::before trả giá trị khác null thì giá trị đó là kết quả cuối
cùng; before trong policy class không được gọi nếu class không có method trùng tên ability. Cho admin qua hết bằng
before là tiện, nhưng nó cũng vượt luôn các luật nghiệp vụ nằm trong policy (vd "không sửa bài đã chấm") — nếu luật đó là
bất biến nghiệp vụ chứ không phải phân quyền, nó nên nằm ở domain model (OWASP: "Unique application business limit
requirements should be enforced by domain models"), không ở policy.
// ===== SAI =====
@UseGuards(JwtAuthGuard)
@Get('submissions/:id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.repo.findOneBy({ id }); // bài của ai cũng trả
}
// ===== ĐÚNG: ownership nằm trong điều kiện truy vấn, ở tầng service =====
// current-user.decorator.ts
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);
// submissions.controller.ts
@Get('submissions/:id')
findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthUser) {
return this.submissions.findVisible(id, user);
}
// submissions.service.ts — controller, GraphQL resolver, queue worker đều gọi hàm này
async findVisible(id: number, user: AuthUser): Promise<Submission> {
const courseIds = await this.enrolments.teachingCourseIds(user.id); // đọc từ DB, không từ claim token
const sub = await this.repo.findOne({
where: [
{ id, studentId: user.id }, // sinh viên: bài của mình
...(courseIds.length ? [{ id, courseId: In(courseIds) }] : []), // giảng viên: khoá mình dạy
],
});
if (!sub) throw new NotFoundException(); // "không có" và "không được xem" trả giống nhau
return sub;
}
listMine(user: AuthUser) {
return this.repo.find({ where: { studentId: user.id }, order: { createdAt: 'DESC' }, take: 20 });
}
Cách nói khi phỏng vấn: "Ở NestJS tôi đặt điều kiện sở hữu vào chính câu truy vấn trong service: tìm bài nộp có ID này và thuộc sinh viên này, hoặc thuộc khoá mà giảng viên này dạy. Không tìm thấy thì trả 404, không phân biệt không tồn tại hay không được xem. Check nằm ở service chứ không ở controller vì cùng dữ liệu đó còn đi ra qua GraphQL và job nền. Danh sách khoá giảng viên dạy được đọc từ database lúc request, để gỡ quyền là có hiệu lực ngay."
Giải thích mẫu đúng:
1. Mảng where trong TypeORM là OR giữa các điều kiện; mỗi nhánh đều mang id cộng với một điều kiện
phạm vi. Không có nhánh nào chỉ có id — đó là cách đọc nhanh khi review: mọi truy vấn theo ID từ request phải đi kèm
một cột phạm vi (user, course, tenant).
2. Vì sao check ở service. Docs NestJS phần Authorization (production checklist) khuyên đúng như vậy: "Put record checks in services, after loading the record, so that controllers, resolvers and queue workers share them", và lọc danh sách bằng điều kiện trong query thay vì load hết rồi kiểm từng dòng. Docs còn cảnh báo một bẫy ít người nghĩ tới: đừng cache response theo URL trên route có check theo bản ghi — interceptor cache trả lời trước khi handler chạy, tức là trả bài của Bình cho An mà không hỏi policy. Cache phải nằm dưới lớp check hoặc theo từng user.
3. Không tin claim token cho quan hệ hay thay đổi. Danh sách khoá giảng viên dạy thay đổi theo học kỳ; nhét vào JWT thì gỡ phân công phải chờ token hết hạn (ví dụ TTL ở 3.3). Đọc từ DB (có cache có invalidation) thì đắt hơn một query nhưng đúng ngay.
4. Lựa chọn mới trong hệ sinh thái Nest. Docs NestJS hiện hành có trang Authorization dùng package
@nestjs/authorization: policy là class @Policy(), mỗi method là một ability; AuthorizationModule.forRoot()
đăng ký guard toàn cục deny by default (handler không khai báo @Can()/@Can.Anyone()/@Public() bị
đóng, và module log danh sách handler bị đóng lúc khởi động); authorize() ném lỗi khi bị từ chối, can() trả
boolean. Docs cũng ghi chú: 403 xác nhận order tồn tại — nếu ID tuần tự và đoán được thì kiểm bằng can() rồi ném
NotFoundException. Tôi chưa chạy package này và chưa xác định nó có từ phiên bản Nest nào (mục Chưa verify); với Nest 11
thì mẫu ở trên (service + điều kiện trong query) hoặc CASL vẫn là cách phổ biến.
flowchart TD
RQ["GET /submissions/:id"] --> AU{"Đã xác thực?"}
AU -->|"không"| E401["401"]
AU -->|"có"| SQ["Query: id = :id AND<br/>(student_id = user OR course_id IN khoá user dạy)"]
SQ --> FD{"Có dòng nào?"}
FD -->|"không"| E404["404 + log denial"]
FD -->|"có"| OK2["200 — trả bản ghi"]
4.2 Function-level: deny by default ở tầng route
// ===== SAI: từng route tự nhớ gắn middleware; route admin chỉ "giấu" bằng prefix =====
Route::get('/admin/grades/export', [GradeExportController::class, 'export']);
// ===== ĐÚNG: nhóm route theo mức quyền, quyền khai báo một lần ở nhóm =====
// app/Providers/AppServiceProvider.php
Gate::define('export-grades', fn (User $user) => $user->hasPermissionTo('grade.export'));
// routes/web.php
Route::middleware('auth')->group(function () {
// ... mọi route của user đã đăng nhập
Route::prefix('admin')->middleware('can:export-grades')->group(function () {
Route::get('/grades/export', [GradeExportController::class, 'export']);
});
});
// tests/Feature/RouteAuthorizationTest.php — "test kiến trúc": route ngoài allowlist mà thiếu auth thì CI đỏ
public function test_every_route_requires_auth_unless_allowlisted(): void
{
$publicUris = ['login', 'forgot-password', 'reset-password/{token}', 'up'];
foreach (Route::getRoutes() as $route) {
if (in_array($route->uri(), $publicUris, true)) {
continue;
}
$hasAuth = collect($route->gatherMiddleware())
->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'));
$this->assertTrue($hasAuth, "Route {$route->uri()} thiếu middleware auth");
}
}
Cách nói khi phỏng vấn: "Laravel không có guard toàn cục kiểu đóng mặc định, nên tôi làm hai việc. Một là gom route theo mức quyền: nhóm đã đăng nhập, nhóm quản trị có middleware kiểm quyền ở cấp nhóm, nên route mới thả vào nhóm nào là tự kế thừa quyền của nhóm đó. Hai là một test duyệt toàn bộ route: route nào không nằm trong danh sách công khai mà thiếu middleware xác thực thì pipeline đỏ. Như vậy quên khai báo quyền bị bắt ở CI chứ không phải ở pentest."
1. Nhóm route là "mặc định" của Laravel. Không có cấu hình nào làm mọi route tự cần đăng nhập, nên cấu trúc
file route chính là chính sách: route nằm trong nhóm nào thì thừa hưởng middleware của nhóm đó. Gate export-grades kiểm
permission chứ không kiểm tên role (Bẫy 8 trong bài RBAC).
2. Test kiến trúc là lưới an toàn của deny by default. Authorization Cheat Sheet khuyên công nghệ kiểm quyền nên
cấu hình được ở mức toàn ứng dụng thay vì gắn từng method. Khi framework không cho, test duyệt Route::getRoutes() đạt hiệu
quả tương đương: danh sách công khai trở thành một danh sách tường minh, được review như code. Cần ý thức rằng package cũng
đăng ký route (vd sanctum/csrf-cookie), và middleware có thể được gắn bằng tên class thay vì alias — điều kiện so khớp phải
điều chỉnh theo dự án. Test này tôi chưa chạy (mục Chưa verify).
// ===== SAI: guard gắn tay từng controller — controller mới quên @UseGuards là mở =====
@Controller('admin/grades')
export class GradeExportController {
@Get('export') export() { /* ... */ }
}
// ===== ĐÚNG: guard toàn cục; route công khai phải khai báo tường minh =====
// public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
// roles.guard.ts — route không khai báo @Roles cũng bị từ chối
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const targets = [ctx.getHandler(), ctx.getClass()];
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, targets)) return true;
const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, targets);
if (!roles) return false; // quên khai báo = đóng
const user = ctx.switchToHttp().getRequest().user;
return roles.some((r) => user?.roles?.includes(r));
}
}
// app.module.ts — APP_GUARD chạy theo thứ tự đăng ký: xác thực trước, phân quyền sau
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
Cách nói khi phỏng vấn: "Ở NestJS tôi đăng ký guard xác thực ở mức toàn ứng dụng, nên mọi route mặc định cần đăng nhập; route công khai phải gắn một decorator đánh dấu tường minh, và decorator đó dễ grep khi review. Guard phân quyền cũng đóng mặc định: route không khai báo vai trò thì bị từ chối. Thứ tự đăng ký quan trọng, xác thực phải chạy trước để có thông tin user rồi phân quyền mới đọc được."
1. Nguồn của mẫu. Phần @Public() + Reflector.getAllAndOverride + APP_GUARD là mẫu "Enable
authentication globally" trong recipe Passport của docs NestJS. Docs Guards ghi: có thể đăng ký APP_GUARD nhiều lần, mọi
guard chạy cho mỗi request theo thứ tự đăng ký.
2. Trade-off của if (!roles) return false. Đóng mặc định ở tầng phân quyền nghĩa là mọi route đã đăng
nhập cũng phải khai báo một thứ gì đó (vd @Roles('student', 'teacher') hoặc một marker "mọi user đã đăng nhập"). Tốn thêm
một dòng mỗi route, đổi lại "quên" biến thành route bị đóng — QA thấy ngay — thay vì route mở. Package
@nestjs/authorization trong docs hiện hành chọn đúng hướng này (@Can.Anyone() là cách mở tường minh, và docs khuyên
review mọi chỗ dùng nó).
3. Bẫy thứ tự (docs Authorization của Nest): guard thêm bằng app.useGlobalGuards() chạy sau mọi
APP_GUARD; nếu guard phân quyền chạy trước guard xác thực thì nó thấy mọi người là khách. Hybrid app với microservice cần
inheritAppConfig: true thì global guard mới chạy trên message handler.
flowchart TD
NR["Route / handler mới"] --> G1{"Có @Public()?"}
G1 -->|"có"| PUB["Cho qua, không cần đăng nhập<br/>(danh sách public được review)"]
G1 -->|"không"| AUTH{"Token hợp lệ?"}
AUTH -->|"không"| R401["401"]
AUTH -->|"có"| DECL{"Handler có khai báo quyền?"}
DECL -->|"không"| R403["403 — quên = đóng"]
DECL -->|"có"| CHK{"User thoả quyền?"}
CHK -->|"không"| R403B["403"]
CHK -->|"có"| GO["Vào handler<br/>(object-level check ở service)"]
4.3 Field đặc quyền: whitelist theo use case
// ===== SAI =====
#[Unguarded] // hoặc kiểu cũ: protected $guarded = [];
class User extends Authenticatable { /* ... */ }
public function update(Request $request)
{
$request->user()->update($request->all()); // body có role, school_id... cũng được ghi
}
// ===== ĐÚNG =====
// app/Models/User.php — Laravel 13 khai báo bằng attribute; property $fillable kiểu cũ vẫn được hỗ trợ
#[Fillable(['name', 'avatar_url'])]
class User extends Authenticatable { /* ... */ }
// app/Http/Requests/UpdateProfileRequest.php
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'avatar_url' => ['nullable', 'url', 'starts_with:https://', 'max:2048'],
];
}
// Controller: chỉ dữ liệu đã validate, không bao giờ $request->all()
public function update(UpdateProfileRequest $request)
{
$request->user()->update($request->validated());
}
// Đổi role là use case riêng, policy riêng — không đi chung đường với "sửa profile"
public function changeRole(ChangeRoleRequest $request, User $target)
{
Gate::authorize('changeRole', $target);
$target->forceFill(['role' => $request->validated('role')])->save();
}
// AppServiceProvider::boot — ngoài production: ném exception khi có field bị bỏ âm thầm
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Cách nói khi phỏng vấn: "Tôi chặn field đặc quyền bằng hai lớp whitelist. Lớp request chỉ cho đi qua những field đã khai báo luật validate, và controller chỉ dùng phần dữ liệu đã validate. Lớp model chỉ cho ghi hàng loạt những cột được khai báo. Những cột như vai trò hay trường học không bao giờ nằm trong use case sửa profile; đổi vai trò là một endpoint khác với policy riêng. Ở môi trường dev và test, tôi bật chế độ ném lỗi khi có field bị bỏ âm thầm để phát hiện lệch sớm."
1. Hai lớp whitelist, mỗi lớp chặn một kiểu quên. $request->validated() chỉ trả các key có trong
rules() — quên thêm luật thì field bị rơi, không bị ghi. #[Fillable] (docs Eloquent 13.x: "all Eloquent models are
protected against mass assignment vulnerabilities by default", phải khai báo Fillable hoặc Guarded trước khi
dùng create) chặn trường hợp ai đó lỡ truyền $request->all(). Source GuardsAttributes nhánh 13.x vẫn có
protected $fillable và gộp thêm giá trị từ attribute Fillable, nên codebase cũ dùng property không phải sửa.
2. forceFill là chỗ cần soi khi review. Nó bỏ qua fillable một cách cố ý; chấp nhận được khi dữ liệu đi
qua policy riêng và validate riêng như changeRole ở trên, không bao giờ với dữ liệu thô từ request.
3. preventSilentlyDiscardingAttributes: mặc định field ngoài fillable bị bỏ âm thầm (docs: production
mong muốn vậy, nhưng ở local gây khó hiểu). Bật nó ngoài production biến "quên khai báo" thành exception lúc dev.
4. Liên hệ: mass assignment là CWE-915, phân loại ở A08:2025 (Bài 8); Livewire có cùng bản chất ở public property (bài Livewire).
// ===== SAI =====
@Patch('me')
update(@CurrentUser() user: AuthUser, @Body() body: any) {
return this.users.update(user.id, body); // body.role, body.schoolId đi thẳng xuống DB
}
// ===== ĐÚNG =====
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
// update-profile.dto.ts — DTO theo USE CASE, không dùng entity làm DTO
export class UpdateProfileDto {
@IsString() @MaxLength(100)
name: string;
@IsOptional() @IsUrl({ protocols: ['https'], require_protocol: true })
avatarUrl?: string;
}
// users.controller.ts
@Patch('me')
update(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {
// map tường minh: kể cả khi DTO sau này có thêm field, chỉ hai cột này được ghi ở use case này
return this.users.update({ id: user.id }, { name: dto.name, avatarUrl: dto.avatarUrl });
}
Cách nói khi phỏng vấn: "Ở NestJS tôi bật ValidationPipe toàn cục với chế độ whitelist và từ chối field lạ: property nào không có decorator validate thì bị loại, hoặc request bị trả lỗi. DTO được viết theo từng use case, không dùng lại entity, nên DTO sửa profile không hề có field vai trò. Ở service tôi vẫn map tường minh từng cột cần ghi, để DTO có phình ra sau này cũng không tự động mở thêm cột."
1. Docs NestJS (Validation → Stripping properties): whitelist: true loại bỏ mọi property không có decorator validate;
thêm forbidNonWhitelisted: true thì từ chối request thay vì lặng lẽ loại. Chọn cái nào: forbid giúp phát hiện client
gửi sai sớm và để lại tín hiệu trong log khi có người thử field lạ; strip thân thiện hơn với client cũ gửi thừa field. Với
API công khai cho nhiều client, nhiều team chọn strip + log.
2. Docs Nest hiện hành cũng có schema-based validation: z.object() của Zod loại key lạ như whitelist, còn
z.strictObject() từ chối như forbidNonWhitelisted.
3. Giới hạn: whitelist chỉ áp lên object được validate. Object lồng nhau cần @ValidateNested() + @Type() để được
duyệt; field kiểu any/JSON tự do không được bảo vệ. Và whitelist chặn field, không chặn giá trị: DTO tạo bài
nộp có field courseId hợp lệ thì vẫn phải kiểm user có đăng ký khoá đó (quay về 4.1).
flowchart TD BD["Request body<br/>name, avatarUrl, role"] --> VP["ValidationPipe / FormRequest<br/>whitelist theo use case"] VP -->|"role bị loại hoặc 400/422"| DT["DTO / validated():<br/>name, avatarUrl"] DT --> MP["Service map tường minh<br/>hoặc Fillable ở model"] MP --> DB2["UPDATE users SET name, avatar_url"] ADM["Use case đổi role<br/>(policy riêng)"] --> DB2
4.4 Đường dẫn file: client gửi ID, server giữ đường dẫn
// ===== SAI: tên file từ query string ghép thẳng vào đường dẫn =====
Route::get('/reports/download', function (Request $request) {
return response()->download(storage_path('app/reports/'.$request->query('file')));
});
// ===== ĐÚNG =====
// config/filesystems.php — disk riêng, nằm ngoài public/, không symlink ra web
'reports' => [
'driver' => 'local',
'root' => storage_path('app/private/reports'),
],
// routes/web.php — client gửi ID; quyền theo bản ghi; đường dẫn do server lưu
Route::get('/reports/{report}/download', function (Report $report) {
// $report->path do job sinh báo cáo ghi vào DB, dạng "2026/10/<uuid>.xlsx" — không chứa input của user
return Storage::disk('reports')->download($report->path, $report->download_name);
})->middleware('auth')->can('download', 'report');
// Khi buộc phải nhận tên file (vd vài file mẫu cố định): allowlist, không "lọc chuỗi"
$templates = ['mau-nhap-diem.xlsx', 'mau-danh-sach-lop.xlsx'];
abort_unless(in_array($name, $templates, true), 404);
return Storage::disk('templates')->download($name);
Cách nói khi phỏng vấn: "Tôi không để client gửi đường dẫn hay tên file lưu trữ. Client gửi ID của bản ghi báo cáo, policy kiểm người đó có được tải báo cáo này không, rồi đường dẫn thật được đọc từ database, nơi chỉ server ghi vào. File nằm trên một disk riêng ngoài thư mục web. Khi bắt buộc phải nhận tên file, tôi so khớp với một danh sách cố định chứ không cố lọc các ký tự nguy hiểm, vì lọc chuỗi là cuộc chơi mình luôn thua."
1. Tách tham chiếu khỏi đường dẫn. IDOR Cheat Sheet khuyên tránh lộ định danh nội bộ ra URL/body khi có thể;
ở đây cái lộ ra là ID bản ghi, còn đường dẫn lưu trữ (2026/10/<uuid>.xlsx) chưa từng rời server. Hai lỗi ở 3.4 bị cắt
cùng lúc: không còn input nào để escape thư mục, và quyền được kiểm theo bản ghi.
2. Flysystem chặn được gì, không chặn được gì (đọc source WhitespacePathNormalizer.php, nhánh 3.x; Laravel
Storage dựng trên Flysystem): đường dẫn chứa ký tự điều khiển bị từ chối (CorruptedPathDetected); thành phần
"thư mục cha" vượt lên trên gốc của disk bị từ chối (PathTraversalDetected); nhưng thành phần "thư mục cha" bên trong
gốc thì được chuẩn hoá và cho qua. Nghĩa là disk ngăn việc thoát ra khỏi root, nhưng hoàn toàn không biết file nào trong
root là của ai — vẫn phải có check theo bản ghi.
3. S3/MinIO: sau khi policy cho phép, trả Storage::temporaryUrl($path, now()->addMinutes(5)) (docs:
Temporary URLs, hỗ trợ local và s3) thay vì cho bucket public. Hạn ngắn vì URL ký sẵn là bearer token: ai có link
là tải được, kể cả khi link bị chuyển tiếp.
// ===== SAI =====
@Get('reports/download')
download(@Query('file') file: string, @Res() res: Response) {
return res.sendFile(join(REPORT_DIR, file));
}
// ===== ĐÚNG =====
import { realpath } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { resolve, sep } from 'node:path';
@Get('reports/:id/download')
async download(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
const report = await this.reports.findVisible(id, user); // ownership như 4.1 → 404 nếu ngoài phạm vi
const root = await realpath(REPORT_DIR); // gốc sau khi resolve symlink
const full = await realpath(resolve(root, report.storedName)); // storedName do server sinh (<uuid>.xlsx)
if (!full.startsWith(root + sep)) throw new NotFoundException(); // phòng thủ chiều sâu: vẫn phải nằm trong gốc
return new StreamableFile(createReadStream(full), {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: `attachment; filename="${report.id}.xlsx"`,
});
}
Cách nói khi phỏng vấn: "Ở Node tôi làm giống hệt: nhận ID, kiểm quyền theo bản ghi, lấy tên file đã lưu do server sinh. Thêm một lớp phòng thủ chiều sâu: resolve đường dẫn thật, kể cả đi theo symlink, rồi kiểm nó vẫn nằm trong thư mục gốc cũng đã được resolve. So sánh phải có dấu phân cách ở cuối thư mục gốc, nếu không một thư mục anh em có tên bắt đầu giống hệt sẽ lọt qua."
1. Vì sao realpath chứ không chỉ resolve. path.resolve chỉ xử lý chuỗi; nó không biết
một thành phần trên đĩa là symlink trỏ ra ngoài (CWE-59/61 trong danh sách A01). realpath hỏi filesystem nên đi theo link
thật. Cả hai phía so sánh đều phải là realpath.
2. Vì sao root + sep. Nếu gốc là /data/reports thì /data/reports-old/x cũng "bắt đầu bằng"
/data/reports. Thêm dấu phân cách loại trường hợp thư mục anh em trùng tiền tố. Đây là lỗi review hay gặp nhất của kiểu
check này.
3. StreamableFile nhận options type, disposition (docs Nest: Streaming files). Tên file tải về dựng từ ID,
không từ dữ liệu người dùng nhập, để khỏi phải escape header.
flowchart TD
DL["GET /reports/:id/download"] --> OW{"Bản ghi report thuộc<br/>phạm vi người gọi?"}
OW -->|"không"| N404["404"]
OW -->|"có"| PATH["Đường dẫn lấy từ DB<br/>(server sinh, không có input user)"]
PATH --> RP["realpath(gốc) và realpath(file)"]
RP --> IN{"file nằm trong gốc + dấu phân cách?"}
IN -->|"không"| N404B["404 + alert (dữ liệu DB bất thường)"]
IN -->|"có"| ST["Stream file / temporaryUrl ngắn hạn"]
4.5 CORS: allowlist tường minh khi có credentials
// config/cors.php — tạo bằng: php artisan config:publish cors
// ===== SAI =====
'allowed_origins' => ['*'],
'supports_credentials' => true, // php-cors khi đó LẶP LẠI mọi Origin gửi tới, kèm credentials
// ===== ĐÚNG =====
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => array_filter(explode(',', env('CORS_ALLOWED_ORIGINS', ''))),
// vd "https://portal.example.edu.vn,https://admin.example.edu.vn"
'allowed_origins_patterns' => [], // nếu buộc dùng: neo ^...$ và escape dấu chấm
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_headers' => ['Content-Type', 'Accept', 'X-Requested-With', 'X-XSRF-TOKEN'],
'supports_credentials' => true, // chỉ bật khi SPA thật sự xác thực bằng cookie
Cách nói khi phỏng vấn: "CORS không phải cơ chế chặn mà là cơ chế nới lỏng, nên tôi coi mỗi origin trong danh sách là một chỗ được phép đọc dữ liệu của user. Khi API dùng cookie, tôi liệt kê chính xác từng origin, lấy từ cấu hình theo môi trường, không dùng dấu sao và hạn chế regex. Tôi biết một chi tiết của thư viện Laravel dùng: nếu để dấu sao mà bật credentials, nó không trả dấu sao mà lặp lại đúng origin của request, tức là mở cho mọi trang."
1. Đọc source để biết * + credentials làm gì. Middleware HandleCors của Laravel 13.x dùng
Fruitcake\Cors\CorsService (thấy ở dòng use của file). Trong CorsService::configureAllowedOrigin() của
fruitcake/php-cors: chỉ khi allowAllOrigins && !supportsCredentials mới trả header *; còn khi có * mà
bật credentials thì rơi vào nhánh "dynamic": isOriginAllowed() trả true ngay khi allowAllOrigins, và header
Access-Control-Allow-Origin được đặt bằng chính Origin của request (kèm Vary: Origin). Tức là trình duyệt không
còn từ chối tổ hợp "sao + credentials" nữa, vì nó không nhìn thấy dấu sao. Đây là đọc source, chưa chạy thật.
2. Mặc định của file cấu hình (config/cors.php nhánh 13.x): allowed_origins => ['*'],
supports_credentials => false. Mặc định đó an toàn chừng nào không ai bật credentials. Sự cố hay đến từ một PR "sửa lỗi
SPA không gửi được cookie" chỉ đổi đúng một dòng supports_credentials.
3. Với Sanctum SPA auth, docs Sanctum có mục "CORS and Cookies" — cấu hình CORS và cookie domain phải đi cùng nhau; đó cũng là chỗ cần review chung với CSRF (4.6).
// ===== SAI =====
app.enableCors({ origin: true, credentials: true }); // true = phản chiếu mọi Origin
app.enableCors({ origin: /example\.edu\.vn$/, credentials: true }); // regex chỉ neo cuối chuỗi
// ===== ĐÚNG =====
const allowed = new Set(
(process.env.CORS_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean), // so khớp nguyên origin
);
app.enableCors({
origin: (origin, cb) => cb(null, origin !== undefined && allowed.has(origin)),
credentials: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
Cách nói khi phỏng vấn: "Trong NestJS, đặt origin bằng true nghĩa là phản chiếu mọi origin, nên tuyệt đối không đi cùng credentials. Regex thì dễ viết thiếu: chỉ neo cuối chuỗi là một domain khác có đuôi giống vẫn khớp. Tôi dùng một tập origin cố định từ cấu hình và so khớp nguyên chuỗi origin, không so khớp từng phần."
1. README của package cors (Express — Nest dùng nó trên Express): origin: true phản chiếu
req.header('Origin'); với RegExp, ví dụ của chính README /example\.com$/ "will reflect any request that is
coming from an origin ending with 'example.com'" — tức là cả những domain không phải của bạn mà tên kết thúc bằng chuỗi đó. Hàm
origin nhận origin và callback callback(err, origin).
2. Docs Nest (CORS) lưu ý thêm một khác biệt nền tảng: enableCors() không tham số trên Express quảng bá
GET,HEAD,PUT,PATCH,POST,DELETE, còn trên Fastify chỉ GET,HEAD,POST — nên khai báo methods tường minh.
3. CORS không thay thế CSRF. CORS quyết định ai đọc được response; form HTML gửi request "simple" tới mọi origin mà không cần preflight (CSRF Cheat Sheet, mục Disallowing simple requests). Chặn đọc không phải chặn gửi.
flowchart TD
OR["Request có header Origin"] --> IN2{"Origin nằm NGUYÊN VĂN<br/>trong allowlist?"}
IN2 -->|"không"| NO["Không gửi header CORS<br/>trình duyệt không cho script đọc"]
IN2 -->|"có"| YES["Allow-Origin = origin đó<br/>Allow-Credentials: true<br/>Vary: Origin"]
YES --> NOTE["Vẫn cần CSRF + phân quyền:<br/>CORS chỉ quyết định việc đọc"]
NO --> NOTE
4.6 CSRF: kiểm nguồn gốc request, token làm lớp dự phòng
// ===== SAI =====
Route::get('/submissions/{submission}/delete', [SubmissionController::class, 'destroy']); // GET đổi trạng thái
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: ['api/*']); // loại cả API dùng cookie session ra khỏi bảo vệ
})
// ===== ĐÚNG =====
Route::delete('/submissions/{submission}', [SubmissionController::class, 'destroy'])
->can('delete', 'submission');
// bootstrap/app.php — PreventRequestForgery (Laravel 13; trước đây là VerifyCsrfToken) có sẵn trong nhóm web.
// Lớp 1: Sec-Fetch-Site = same-origin thì cho qua. Lớp 2: không có header / không phải HTTPS thì kiểm CSRF token.
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'webhooks/payment', // chỉ webhook server-to-server, tự xác thực bằng chữ ký của nhà cung cấp
]);
})
// config/session.php — mặc định của 13.x đã là 'lax'
'same_site' => env('SESSION_SAME_SITE', 'lax'),
'secure' => env('SESSION_SECURE_COOKIE', true), // mặc định của file là null; ở đây chọn bật
// Blade: mọi form POST/PUT/PATCH/DELETE có @csrf (lớp token cho trình duyệt không gửi Sec-Fetch-Site)
Cách nói khi phỏng vấn: "Từ Laravel 13, middleware chống CSRF kiểm hai lớp: trình duyệt hiện đại tự gửi header cho biết request đến từ cùng origin hay từ site khác, cùng origin thì cho qua; không có header đó thì quay về kiểm token trong form như trước. Cookie session để SameSite Lax làm lớp chiều sâu. Hai điều tôi canh khi review: không có route GET nào đổi dữ liệu, vì mọi lớp này đều để GET đi qua; và danh sách loại trừ chỉ chứa webhook có chữ ký, không bao giờ loại cả nhóm API dùng cookie."
1. Laravel 13 đổi cơ chế. Upgrade guide 13.0: middleware đổi tên từ VerifyCsrfToken thành
PreventRequestForgery, "and now includes request-origin verification using the Sec-Fetch-Site header"; tên cũ
còn là alias deprecated. Docs CSRF 13.x: same-origin theo Sec-Fetch-Site thì cho qua không cần token; nếu không đạt (trình
duyệt cũ, kết nối không an toàn) thì quay về token. Tuỳ chọn: originOnly: true (bỏ hẳn token, lỗi thành 403 thay vì 419),
allowSameSite: true (cho subdomain cùng site). Docs lưu ý Sec-Fetch-Site chỉ được gửi qua HTTPS.
2. Khớp với khuyến nghị OWASP. CSRF Cheat Sheet: dùng cơ chế có sẵn của framework trước; Fetch Metadata là cách
nhẹ và tin cậy để chặn request cross-site, nhưng bắt buộc có fallback (kiểm Origin chuẩn hoặc token) cho trình duyệt không gửi
Sec-Fetch-*; same-site chỉ nên tin nếu threat model tin các subdomain anh em. Cách Laravel 13 làm — Fetch Metadata
rồi fallback token — đúng hình mẫu đó; allowSameSite là quyết định threat model, không phải tiện ích.
3. SameSite là lớp chiều sâu, không phải lớp chính. Cheat Sheet liệt kê giới hạn: Lax vẫn gửi cookie với
điều hướng top-level bằng method an toàn (nên GET có side effect là thủng); SameSite tính theo registrable domain chứ không
theo origin, nên mọi subdomain (kể cả subdomain bị chiếm hoặc host nội dung người dùng) đều là "same-site"; trình duyệt cũ/nhúng
có thể không áp dụng. Cheat Sheet chỉ chấp nhận SameSite đứng một mình khi thoả tất cả điều kiện (không chung domain với host
không kiểm soát, không GET nào đổi state, Strict hoặc Lax + __Host-, có kiểm Origin...).
4. Loại trừ đúng cách. Docs Laravel khuyên đặt route webhook ra ngoài nhóm web; chỉ khi không được mới dùng
except. Webhook không có cookie session nên CSRF không áp dụng, nhưng nó phải tự xác thực bằng chữ ký — nếu không thì
"bỏ CSRF" đồng nghĩa với "endpoint không ai xác thực".
// ===== SAI =====
@Get('submissions/:id/delete')
remove(@Param('id') id: string) { /* ... */ } // GET có side effect
// ===== ĐÚNG: NestJS 12.1+ (theo docs) — kiểm Sec-Fetch-Site / Origin, không cần token hay cookie phụ =====
const app = await NestFactory.create(AppModule);
app.enableCsrfProtection({
trustedOrigins: ['https://admin.example.edu.vn'], // frontend khác origin, có gửi cookie
exclude: [{ path: 'webhooks/payment', method: RequestMethod.POST }], // server-to-server, có chữ ký riêng
});
await app.listen(process.env.PORT ?? 3000); // phải gọi enableCsrfProtection trước listen
// Nest 11 trở xuống, hoặc phải hỗ trợ trình duyệt rất cũ: token-based (docs gợi ý csrf-csrf cho Express,
// @fastify/csrf-protection cho Fastify)
Cách nói khi phỏng vấn: "NestJS từ 12.1 có chống CSRF dựng sẵn theo cùng thuật toán với Go: GET, HEAD, OPTIONS luôn cho qua; có header Fetch Metadata thì chỉ nhận cùng origin hoặc điều hướng do user tự mở; không có header đó thì so Origin với Host. Frontend ở origin khác phải được khai báo tin cậy tường minh, và origin được CORS cho phép không tự động được tin cho CSRF. Phiên bản cũ hơn thì dùng thư viện token double-submit."
1. Docs NestJS (Security → CSRF Protection) mô tả thuật toán bốn bước: GET/HEAD/OPTIONS luôn cho qua; nếu có
Sec-Fetch-Site thì chỉ same-origin và none được nhận (kể cả same-site cũng bị từ chối); không có
Sec-Fetch-Site lẫn Origin thì cho qua vì không phải trình duyệt; còn lại thì host của Origin phải khớp
Host (bỏ qua X-Forwarded-Host). Bị từ chối thì thành ForbiddenException 403.
2. Giới hạn docs tự nêu, đáng nhắc khi phỏng vấn: không phải token scheme; GET/HEAD/OPTIONS không bao giờ bị kiểm nên handler
của chúng không được đổi state; fallback Origin/Host không biết scheme (HSTS giảm rủi ro này); sau proxy viết lại Host thì
trình duyệt không gửi Sec-Fetch-Site sẽ nhận 403; WebSocket upgrade không được kiểm — gateway xác thực bằng
cookie phải tự kiểm Origin của handshake; và "Origins allowed by CORS are not trusted automatically".
3. API xác thực bằng header Authorization: Bearer do JavaScript tự gắn không bị CSRF cổ điển, vì trình duyệt không tự gửi
header đó; docs Nest cũng nói client không phải trình duyệt (mobile, CLI, server) không cần chống CSRF. Nhưng token cất trong
cookie thì quay lại đúng bài toán CSRF.
flowchart TD
RQ2["Request tới endpoint dùng cookie"] --> MT{"Method an toàn?<br/>(GET, HEAD, OPTIONS)"}
MT -->|"có"| PASS["Cho qua — handler<br/>KHÔNG được đổi state"]
MT -->|"không"| SF{"Có Sec-Fetch-Site?"}
SF -->|"same-origin"| OK3["Cho qua"]
SF -->|"cross-site / same-site*"| BL["Từ chối 403<br/>(*trừ khi tin subdomain)"]
SF -->|"không có"| FB{"Fallback: token hợp lệ<br/>hoặc Origin khớp?"}
FB -->|"có"| OK3
FB -->|"không"| BL
4.7 SSRF: allowlist, kiểm IP sau khi phân giải, không redirect, egress
// ===== SAI =====
$image = Http::get($request->input('avatar_url'))->body(); // fetch bất kỳ đâu, mặc định còn follow redirect
// ===== ĐÚNG — Case 1 (đích đã biết): client gửi khoá nghiệp vụ, server tự dựng URL =====
// FormRequest: 'provider' => ['required', Rule::in(array_keys(config('services.avatar_providers')))]
$base = config('services.avatar_providers')[$request->validated('provider')]; // vd https://cdn.partner.example
$response = Http::withOptions(['allow_redirects' => false])
->timeout(5)->connectTimeout(2)
->get($base.'/avatars/'.rawurlencode($request->validated('image_id')));
// ===== ĐÚNG — Case 2 (đích bất kỳ trên Internet, vd webhook của khách hàng) =====
final class PublicHttpsFetcher
{
public function fetch(string $url): string
{
$p = parse_url($url);
if (($p['scheme'] ?? null) !== 'https' || empty($p['host'])
|| isset($p['user']) || isset($p['pass']) || (isset($p['port']) && $p['port'] !== 443)
|| filter_var(trim($p['host'], '[]'), FILTER_VALIDATE_IP) !== false) { // không nhận IP literal
throw new InvalidArgumentException('URL không hợp lệ');
}
$host = strtolower($p['host']);
// Lấy TẤT CẢ bản ghi A và AAAA; mọi địa chỉ đều phải là địa chỉ công cộng
$ips = array_merge(
array_column(dns_get_record($host, DNS_A) ?: [], 'ip'),
array_column(dns_get_record($host, DNS_AAAA) ?: [], 'ipv6'),
);
if ($ips === []) {
throw new InvalidArgumentException('Không phân giải được host');
}
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) { // PHP 8.2+
throw new InvalidArgumentException('Đích nằm trong dải không cho phép');
}
}
$pin = str_contains($ips[0], ':') ? "[{$ips[0]}]" : $ips[0];
// Tự dựng lại URL từ phần đã parse, không chuyển nguyên chuỗi gốc cho parser thứ hai
$rebuilt = 'https://'.$host.($p['path'] ?? '/').(isset($p['query']) ? '?'.$p['query'] : '');
return Http::withOptions([
'allow_redirects' => false,
// Ghim kết nối vào IP vừa kiểm: không phân giải DNS lần hai (chống DNS rebinding).
// Host header, SNI và kiểm chứng chỉ TLS vẫn dùng tên miền gốc.
'curl' => [CURLOPT_RESOLVE => ["{$host}:443:{$pin}"]],
])
->timeout(5)->connectTimeout(2)
->get($rebuilt)
->throw()
->body();
}
}
Cách nói khi phỏng vấn: "Câu hỏi đầu tiên với SSRF là tính năng cần gọi tới đích đã biết hay đích bất kỳ. Đích đã biết thì client chỉ gửi một khoá nghiệp vụ, URL do server dựng từ cấu hình, gần như không còn bề mặt. Đích bất kỳ, như webhook của khách, thì tôi chỉ nhận HTTPS cổng chuẩn, không nhận địa chỉ IP trực tiếp, phân giải tên miền ra tất cả địa chỉ và yêu cầu mọi địa chỉ đều là địa chỉ công cộng, rồi ghim kết nối vào đúng địa chỉ vừa kiểm để không có lần phân giải thứ hai. Tắt redirect, đặt timeout. Và tầng ứng dụng không phải lớp cuối: máy chạy tính năng này phải bị giới hạn đường ra ở tầng mạng."
Giải thích mẫu đúng, theo SSRF Prevention Cheat Sheet:
1. Case 1 — không nhận URL. Cheat Sheet: "Do not accept complete URLs from the user"; nếu cần thông tin mạng
thì chỉ nhận IP hoặc domain, so với allowlist, "and build the request yourself" với scheme, port, path do ứng dụng cố định.
Mẫu trên đi xa hơn một bước: client không gửi cả domain, chỉ gửi khoá provider. Chú ý dòng Rule::in(...): nếu dùng
input làm một phần của key config('a.b.'.$x) mà không validate, dấu chấm trong input có thể trỏ sang key cấu hình khác — lỗi
không phải SSRF nhưng sinh ra từ cùng thói quen ghép chuỗi.
2. Case 2 — kiểm IP sau phân giải, và kiểm mọi IP. Cheat Sheet (Case 2, validation flow): lấy mọi bản ghi A +
AAAA và áp kiểm tra "địa chỉ công cộng" cho từng cái; chỉ kiểm tên miền không đủ. FILTER_FLAG_GLOBAL_RANGE (PHP 8.2+) là cách
gọn trong PHP; với bản PHP cũ hơn hoặc cần chắc chắn từng dải, so với bảng dải tối thiểu của Cheat Sheet (mục Deny-list): metadata
cloud 169.254.169.254, loopback 127.0.0.0/8, 0.0.0.0/8, ::1/128, RFC1918
10/8, 172.16/12, 192.168/16, IPv6 unique-local fc00::/7, link-local fe80::/10, multicast. Tôi
chưa đối chiếu chính xác GLOBAL_RANGE loại những dải nào (mục Chưa verify).
3. Ghim kết nối (connection-time enforcement). Cheat Sheet: một lần phân giải DNS thứ hai, không được kiểm, giữa lúc
validate và lúc kết nối có thể vượt qua mọi kiểm tra; dùng cơ chế của HTTP client để kết nối tới IP đã kiểm trong khi vẫn giữ
hostname cho Host header, SNI và kiểm chứng chỉ — Cheat Sheet dẫn đúng tính năng custom address resolution của curl.
CURLOPT_RESOLVE qua option curl của Guzzle là cách đó trong PHP (chỉ có hiệu lực khi Guzzle chạy bằng curl handler —
mục Chưa verify). Cheat Sheet cũng nhắc áp chính sách đích cho cả retry và kết nối dự phòng.
4. Tắt redirect, dựng lại URL. Cheat Sheet yêu cầu tắt follow redirect trong HTTP client. Docs Laravel HTTP Client
có ví dụ Http::globalOptions(['allow_redirects' => false]) — đặt ở AppServiceProvider thì mọi request ra ngoài của app
mặc định không follow redirect, ai cần thì bật tường minh. Dựng lại URL từ phần đã parse thay vì đưa nguyên chuỗi gốc cho Guzzle là
để tránh "parser disagreement" — Cheat Sheet khuyên coi việc hai parser đọc ra hai host khác nhau là lý do từ chối.
5. Những gì code này không làm: không giới hạn kích thước response (nên stream và cắt ở N byte), không chống được dịch vụ công cộng mà chính bạn tin cậy theo IP, và không thay được lớp mạng. Cheat Sheet: lớp Network (firewall, network segregation) "is highly recommended in order to block illegitimate calls directly at network level itself", và trên AWS chuyển sang IMDSv2, tắt IMDSv1.
import { lookup as dnsLookup, type LookupOptions } from 'node:dns';
import { BlockList, isIP } from 'node:net';
import https from 'node:https';
// Dải không cho phép — theo bảng tối thiểu của OWASP SSRF Cheat Sheet + IPv4-mapped IPv6
const blocked = new BlockList();
for (const [net, len] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['224.0.0.0', 4], ['240.0.0.0', 4]] as const) {
blocked.addSubnet(net, len, 'ipv4');
}
for (const [net, len] of [['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7], ['fe80::', 10],
['ff00::', 8]] as const) {
blocked.addSubnet(net, len, 'ipv6');
}
// lookup tuỳ biến: chạy ĐÚNG lúc socket kết nối → IP được kiểm chính là IP được dùng
function safeLookup(hostname: string, options: LookupOptions, callback: (...args: any[]) => void) {
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const bad = addresses.find((a) => blocked.check(a.address, a.family === 6 ? 'ipv6' : 'ipv4'));
if (bad || addresses.length === 0) return callback(new Error('destination not allowed'));
if (options.all) return callback(null, addresses); // Node có thể gọi lookup với all: true
callback(null, addresses[0].address, addresses[0].family);
});
}
export function fetchPublicHttps(raw: string, maxBytes = 2_000_000): Promise<Buffer> {
const url = new URL(raw);
const bareHost = url.hostname.replace(/^\[|\]$/g, '');
if (url.protocol !== 'https:' || url.username || url.password || (url.port && url.port !== '443')
|| isIP(bareHost) !== 0) { // IP literal không đi qua lookup → từ chối
throw new BadRequestException('URL không hợp lệ');
}
return new Promise((resolve, reject) => {
const req = https.get(url, { lookup: safeLookup, timeout: 5000 }, (res) => {
if (res.statusCode !== 200) { // 3xx cũng bị từ chối: không follow redirect
res.resume();
return reject(new BadRequestException(`upstream status ${res.statusCode}`));
}
const chunks: Buffer[] = [];
let size = 0;
res.on('data', (c: Buffer) => {
size += c.length;
if (size > maxBytes) req.destroy(new Error('response quá lớn'));
else chunks.push(c);
});
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
});
}
Cách nói khi phỏng vấn: "Ở Node, chỗ đặt kiểm tra tốt nhất là hàm lookup mà socket gọi ngay lúc kết nối, vì như vậy địa chỉ được kiểm và địa chỉ được dùng là một, không có khe hở giữa hai lần phân giải. Hàm đó phân giải ra mọi địa chỉ, chỉ cần một địa chỉ thuộc dải nội bộ là từ chối. Địa chỉ IP viết thẳng trong URL thì không đi qua lookup, nên tôi từ chối luôn. Module https của Node không tự follow redirect; tôi coi mọi mã khác 200 là lỗi, đặt timeout và giới hạn kích thước."
1. Vì sao kiểm trong lookup. Docs Node (http.request) cho phép truyền lookup tuỳ biến thay
dns.lookup; socket gọi nó khi kết nối, nên đây là "connection-time enforcement" mà Cheat Sheet yêu cầu, không cần tự ghim IP
như bên PHP. Hai chi tiết dễ sai (cả hai tôi chưa chạy — mục Chưa verify): (a) Node bản mới có thể gọi lookup với
all: true (do cơ chế tự chọn họ địa chỉ) và chờ callback dạng mảng — hàm trên xử lý cả hai dạng; (b) host là IP literal thì
socket không gọi lookup, nên phải kiểm hoặc từ chối IP literal trước.
2. Thư viện HTTP khác. axios/got mặc định follow redirect — phải tắt (vd maxRedirects: 0 với axios) và
kiểm chúng có truyền lookup xuống agent không. Với fetch dựng sẵn (undici) thì cấu hình khác hẳn; đừng giả định
cùng một option chạy ở mọi client.
3. Lớp mạng (egress). Dù code đúng, máy chạy tính năng fetch URL nên đi ra ngoài qua một egress proxy có allowlist/deny-list dải nội bộ (ví dụ mã nguồn mở: Smokescreen của Stripe), hoặc nằm ở mạng con không có route tới DB, Redis, metadata. Cheat Sheet gọi đây là lớp Network và khuyến nghị mạnh. Lý do kiến trúc: deny-list ở tầng ứng dụng "are bypass-prone" (chữ của Cheat Sheet) — lớp mạng là chỗ đỡ khi tầng ứng dụng sót.
flowchart TD
IN3["URL / khoá nghiệp vụ từ user"] --> K1{"Đích đã biết trước?"}
K1 -->|"có (Case 1)"| AL["Allowlist theo khoá → server tự dựng URL"]
K1 -->|"không (Case 2)"| PV["Parse 1 lần: chỉ https, cổng 443,<br/>không userinfo, không IP literal"]
PV --> RS["Phân giải DNS → mọi A + AAAA"]
RS --> PUB2{"Mọi IP đều công cộng?"}
PUB2 -->|"không"| REJ["Từ chối + log"]
PUB2 -->|"có"| PIN["Kết nối tới đúng IP đã kiểm<br/>(ghim / lookup lúc connect)"]
AL --> RQ3["Request: không redirect,<br/>timeout, giới hạn kích thước"]
PIN --> RQ3
RQ3 --> EG["Egress proxy / firewall:<br/>lớp cuối ở tầng mạng"]
4.8 403 hay 404, và ghi log mọi lần từ chối
// Laravel — policy: object ngoài phạm vi trả 404, không xác nhận sự tồn tại
return Response::denyAsNotFound();
// AppServiceProvider::boot — một chỗ duy nhất ghi log mọi quyết định "từ chối" của Gate/Policy
Gate::after(function (?User $user, string $ability, $result, array $arguments) {
$denied = $result === false || ($result instanceof Response && $result->denied());
if ($denied) {
Log::channel('security')->warning('authz.denied', [
'user_id' => $user?->id,
'ability' => $ability,
'objects' => collect($arguments)
->map(fn ($a) => $a instanceof Model ? $a::class.'#'.$a->getKey() : get_debug_type($a))
->all(),
'ip' => request()->ip(),
]);
}
// không return gì → không ghi đè kết quả kiểm tra
});
Cách nói khi phỏng vấn: "Với bản ghi mà sự tồn tại của nó cũng là thông tin nhạy cảm, tôi trả 404 cho cả trường hợp không có và không được xem, để không ai dùng mã lỗi để dò xem ID nào tồn tại. Đổi lại, log phía server phải ghi rõ đây là từ chối phân quyền chứ không phải không tìm thấy, kèm user, hành động và bản ghi. Tôi ghi log ở một hook sau mọi lần kiểm quyền, nên không phụ thuộc từng developer nhớ log, và hook đó không được thay đổi kết quả kiểm tra."
1. Docs Laravel về Gate::after: closure chạy sau mọi kiểm tra; giá trị nó trả về không ghi đè kết quả trừ khi
gate/policy trả null. Closure ở trên không trả gì nên chỉ quan sát. Kiểu của $result khi policy trả
Response thay vì bool tôi chưa kiểm, nên điều kiện xử lý cả hai (mục Chưa verify). Với Nest package mới, docs
khuyên subscribe AuthorizationEvents để log policy, ability và user ID cho mọi lần từ chối, vì response không nói check nào
đã từ chối.
2. Trade-off 403 so với 404 — không có đáp án chung:
| Tiêu chí | Trả 403 | Trả 404 cho cả "không có" và "không được xem" |
|---|---|---|
| Lộ thông tin | Xác nhận object tồn tại (docs Nest: "A 403 confirms that the order exists") | Không phân biệt được; IDOR Cheat Sheet khuyên dùng khi sự tồn tại là nhạy cảm (vd GET /user/<email>) |
| Trải nghiệm người dùng hợp lệ | Rõ ràng: "anh không có quyền" → biết đi xin quyền | Khó hiểu: link đồng nghiệp gửi "không tồn tại" |
| Hỗ trợ/debug | Dễ | Khó, nếu log phía server không ghi rõ lý do |
| Giám sát | Đếm 403 theo user là tín hiệu thẳng | 404 lẫn với link hỏng; phải dựa vào log authz.denied |
| Hợp với | Tài nguyên trong cùng tổ chức, người dùng biết nó tồn tại (trang quản trị, tài liệu nội bộ) | Dữ liệu cá nhân, ID tuần tự đoán được, tài nguyên của tenant khác |
Quy tắc tôi dùng: 401 khi chưa xác thực; 403 khi đã xác thực nhưng không được dùng chức năng (người dùng đã biết chức năng tồn tại); 404 khi bản ghi ngoài phạm vi và ID có thể đoán/ghép được. Quan trọng hơn mã nào: nhất quán trên mọi endpoint của cùng loại tài nguyên — một endpoint 403, endpoint export cùng loại lại 404 thì phân biệt được bằng cách so hai endpoint.
flowchart TD
Q0{"Đã xác thực?"} -->|"không"| C401["401"]
Q0 -->|"có"| Q1{"Được dùng CHỨC NĂNG này?"}
Q1 -->|"không"| C403["403"]
Q1 -->|"có"| Q2{"Bản ghi trong phạm vi?"}
Q2 -->|"có"| C200["200"]
Q2 -->|"không"| Q3{"Sự tồn tại là nhạy cảm<br/>hoặc ID đoán được?"}
Q3 -->|"có"| C404["404 + log authz.denied"]
Q3 -->|"không"| C403B["403 + log authz.denied"]
5. Phòng thủ nhiều lớp — mỗi lớp chặn gì, không chặn gì
Không có "một cách chuẩn" chống A01. Mỗi lớp dưới đây chặn một giả định sai cụ thể ở mục 3 và để lọt những giả định khác; giá trị nằm ở việc chồng chúng lên nhau sao cho chỗ lọt của lớp này là chỗ chặn của lớp kia. Cột "hợp khi" là bối cảnh mà lớp đó đáng chi phí.
5.1 Bảng tổng hợp
| Lớp | Chặn được | KHÔNG chặn được | Hợp khi |
|---|---|---|---|
| Deny by default ở route (global guard, nhóm route + test kiến trúc) | Route mới quên auth/quyền chức năng; force browsing | IDOR — người gọi có đủ quyền chức năng nhưng sai bản ghi | Mọi ứng dụng; rẻ nhất, làm đầu tiên |
| Ownership/tenant trong query | IDOR khi đọc và liệt kê; rò dữ liệu qua phân trang/đếm | Luật phụ thuộc trạng thái/thời gian; đường đi không dùng repository đó (raw SQL, báo cáo, script) | CRUD theo chủ sở hữu hoặc tenant |
Policy tập trung trong app (Laravel Gate/Policy, Nest @nestjs/authorization, CASL) | Logic rải rác, mỗi endpoint một kiểu; luật nhiều điều kiện | Quên gọi policy (nếu không gắn ở route); lệch giữa policy và query của danh sách | Luật có nhiều điều kiện, một codebase |
| Policy engine tách riêng (OPA, Cedar, OpenFGA...) | Nhất quán giữa nhiều service/ngôn ngữ; audit và review luật tập trung | Thêm latency + điểm hỏng mới (PDP sập thì fail open hay closed?); lọc danh sách khó; đồng bộ thuộc tính | Nhiều service, yêu cầu compliance; xem ABAC mục 1.6, 4.2 |
| Row-Level Security ở PostgreSQL | App quên điều kiện tenant; đường đi ngoài app (báo cáo, script) nếu dùng đúng role | App kết nối bằng owner của bảng (RLS tắt âm thầm); biến session rò qua connection pool (ABAC Bẫy 3, 4); MySQL không có tính năng tương đương | Multi-tenant trên PostgreSQL, dữ liệu nhạy cảm |
| ID ngẫu nhiên (UUID, ULID ngẫu nhiên) | Dò tuần tự hàng loạt | ID lộ qua link, log, email — cần access check (IDOR Cheat Sheet) | Phòng thủ chiều sâu, nhất là tài nguyên công khai qua link |
| Whitelist field (DTO whitelist, FormRequest + Fillable) | Leo quyền qua field đặc quyền | Giá trị sai trong field hợp lệ (vd courseId của khoá không đăng ký) | Mọi endpoint ghi |
| Chống CSRF (Fetch Metadata + token + SameSite) | Request cross-site mượn cookie | XSS (script cùng origin); GET đổi state; subdomain bị chiếm khi tin same-site | Mọi app xác thực bằng cookie |
| CORS allowlist chính xác | Trang lạ đọc response bằng cookie của user | CSRF (chặn đọc, không chặn gửi); origin trong list bị XSS | SPA khác origin dùng cookie |
| SSRF ở tầng app (allowlist / kiểm IP lúc kết nối / không redirect) | Fetch tới dải nội bộ, metadata cloud | Bug parser chưa biết; thư viện tự follow redirect; dịch vụ công cộng mà nội bộ tin theo IP | Mọi tính năng fetch URL do user cung cấp |
| Egress ở tầng mạng (proxy ra ngoài, network segregation, IMDSv2) | Phần lớn lỗ của tầng app (lớp đỡ cuối) | Request tới đích được phép nhưng mang dữ liệu sai; cần hạ tầng và người vận hành | Cloud, hoặc khi có tính năng webhook/fetch URL |
| Rate limit (OWASP prevention) | Dò ID hàng loạt bằng tool; giảm thiệt hại khi có lỗ | Bản thân lỗ hổng; kẻ tấn công xoay IP (vụ Optus: "rotating through tens of thousands of different IP addresses") | API đọc theo ID; xem series rate limit |
| Session huỷ khi logout, JWT sống ngắn + refresh/thu hồi | Quyền cũ còn hiệu lực sau khi gỡ | Cửa sổ còn lại bằng TTL nếu quyết định đọc từ claim | Mọi hệ thống có token |
| Log + alert từ chối (A09) | Phát hiện dò quét, điều tra phạm vi lộ | Không ngăn gì; log sai thì vô dụng (Bài 9) | Bắt buộc với dữ liệu cá nhân |
| Test âm + test ma trận quyền | Regression khi refactor | Loại lỗi chưa ai nghĩ tới (cần review, pentest) | Mọi dự án; OWASP yêu cầu |
5.2 Policy tập trung: trong app hay engine riêng
1. OWASP prevention: "Implement access control mechanisms once and reuse them throughout the application" và "Use well-established toolkits or patterns that provide simple, declarative access controls". "Một lần" có thể là một module trong app (Laravel Policy, Nest policy class) hoặc một engine riêng (PDP — policy decision point) mà mọi service hỏi qua mạng.
2. Trong app thắng ở độ trễ (gọi hàm), ở việc lọc danh sách (luật dịch được thành WHERE ngay trong ORM) và ở
debug. Thua khi có nhiều service viết bằng nhiều ngôn ngữ: mỗi service một bản copy luật, sớm muộn sẽ lệch. Engine riêng
(OPA với Rego, Cedar, hay mô hình quan hệ kiểu Zanzibar như OpenFGA) thắng ở nhất quán và audit (luật là một artifact được review,
version, test riêng) nhưng thêm một phụ thuộc trên đường nóng của mọi request; câu hỏi "PDP không trả lời thì sao" phải có đáp án
fail closed — đó là chủ đề của A10. Bài
ABAC đã đo và phân tích OPA (kể cả bẫy sai path vẫn trả HTTP 200), ở đây không lặp lại.
3. Với quy mô trong CV (một vài codebase, team 4–7 người), lựa chọn hợp lý là policy trong app + test, và chỉ cân nhắc engine riêng khi số service dùng chung luật tăng lên. Đây là quyết định đáng ghi vào ADR: điều kiện nào thì chuyển (số service, yêu cầu audit), và exit strategy (luật viết dưới dạng hàm thuần, dễ port).
5.3 Cô lập tenant — trường hợp đặc biệt của object-level
1. Multi-tenant (nhiều trường dùng chung một hệ thống) là IDOR ở cấp lớn hơn: thay vì "bài của Bình", là "toàn bộ dữ liệu của
trường khác". Ba mô hình phổ biến, đánh đổi giữa chi phí vận hành và độ cô lập: chung bảng + cột tenant_id (rẻ nhất,
dễ quên điều kiện nhất); schema/database riêng mỗi tenant (cô lập mạnh, migration và vận hành nhân theo số tenant);
chung bảng + RLS (DB tự thêm điều kiện, nhưng có các bẫy kết nối đã nói).
2. Với chung bảng trong Laravel, cách hay dùng là global scope tự thêm where school_id = ? cho model. Điểm yếu cần biết
khi phỏng vấn: withoutGlobalScopes() tắt nó; query builder thô (DB::table) không đi qua model; job trong queue chạy
không có request/user nên scope phải lấy tenant từ payload của job chứ không từ "user hiện tại"; cache key không có tiền tố tenant
là rò dữ liệu dù query đúng. Unique index toàn bảng cũng làm lộ sự tồn tại dữ liệu của tenant khác
(ABAC Bẫy 9).
5.4 Nếu chỉ có một sprint: thứ tự ưu tiên
1. Deny by default ở route + test kiến trúc (một ngày, chặn cả một lớp lỗi về sau). 2. Rà mọi endpoint nhận ID:
thêm điều kiện phạm vi vào query hoặc policy ở route, bắt đầu từ endpoint trả dữ liệu cá nhân và endpoint export. 3. Test âm
hai tài khoản cho các endpoint đó. 4. Whitelist field ở mọi endpoint ghi. 5. Rà CSRF/CORS: không GET nào đổi state,
không except rộng, không reflect origin. 6. Tính năng fetch URL: liệt kê, chuyển Case 1 được thì chuyển, còn lại
đưa qua một module fetch an toàn duy nhất. 7. Log denial + alert. Thứ tự dựa trên tỉ lệ lỗi/chi phí sửa, không dựa trên độ
"hay" của kỹ thuật.
6. Phát hiện & vận hành
6.1 Code review: mẫu cần soi
| Thấy trong diff | Câu hỏi khi review | Hướng sửa |
|---|---|---|
Model::find($id), findOrFail, findOneBy({ id }) với ID từ request | Điều kiện phạm vi (user/course/tenant) ở đâu? Có policy ở route không? | 4.1 |
Route mới ngoài nhóm có auth; controller Nest có @Public()/@Can.Anyone() | Có thật sự công khai không? Ai đã duyệt? | 4.2 |
$request->all(), #[Unguarded], $guarded = [], forceFill, @Body() body: any | Field đặc quyền có lọt không? | 4.3 |
Ghép chuỗi đường dẫn với input; sendFile, response()->download | Input có thành đường dẫn không? Có check theo bản ghi không? | 4.4 |
allowed_origins => ['*'] cùng supports_credentials; origin: true; regex origin | Có credentials không? Regex có neo không? | 4.5 |
Route::get làm thay đổi dữ liệu; except trong cấu hình CSRF | Có side effect không? Route loại trừ tự xác thực bằng gì? | 4.6 |
Http::get($input), fetch(userUrl), axios.get(url), thư viện xử lý ảnh/PDF nhận URL | Case 1 hay Case 2? Redirect? Kiểm IP lúc kết nối? | 4.7 |
| Interceptor/middleware cache theo URL trên route có dữ liệu cá nhân | Cache nằm trên hay dưới lớp check? | 4.1 |
Check quyền chỉ ở Blade/React (@can, ẩn nút) | Server có check tương ứng không? | 3.2 |
6.2 Test âm và ma trận quyền
// tests/Feature/SubmissionAuthorizationTest.php
public function test_student_cannot_view_another_students_submission(): void
{
[$an, $binh] = User::factory()->count(2)->create()->all();
$submission = Submission::factory()->create(['student_id' => $binh->id]);
$this->actingAs($an)
->getJson("/api/submissions/{$submission->id}")
->assertNotFound(); // không lộ sự tồn tại
$this->actingAs($binh)
->getJson("/api/submissions/{$submission->id}")
->assertOk(); // chủ sở hữu vẫn xem được — test âm phải đi cùng test dương
}
Cách nói khi phỏng vấn: "Mỗi endpoint nhận ID có ít nhất một cặp test: người không sở hữu phải nhận 404, và người sở hữu vẫn phải nhận 200. Thiếu vế thứ hai thì một policy từ chối tất cả cũng làm test xanh. Tôi tạo hai user bằng factory, gán bản ghi cho người thứ hai, rồi đăng nhập bằng người thứ nhất để gọi."
// test/submissions.authz.e2e-spec.ts — ma trận vai trò × đối tượng × kết quả mong đợi
describe('Submissions authorization', () => {
it.each([
['An xem bài của An', () => anToken, () => anSubmissionId, 200],
['An xem bài của Bình', () => anToken, () => binhSubmissionId, 404],
['Cường (dạy khoá) xem bài của Bình', () => cuongToken, () => binhSubmissionId, 200],
['Giảng viên khoá khác xem bài Bình', () => dungToken, () => binhSubmissionId, 404],
['Không có token', () => '', () => binhSubmissionId, 401],
])('%s', async (_name, token, id, status) => {
const req = request(app.getHttpServer()).get(`/submissions/${id()}`);
if (token()) req.set('Authorization', `Bearer ${token()}`);
await req.expect(status);
});
});
Cách nói khi phỏng vấn: "Tôi viết test phân quyền dưới dạng bảng: mỗi dòng là một vai trò, một bản ghi và mã trạng thái mong đợi, gồm cả người sở hữu, người lạ, giảng viên đúng khoá, giảng viên khoá khác và khách chưa đăng nhập. Bảng này đọc được như một đặc tả, PO hay QC cũng review được, và khi thêm vai trò mới thì chỉ thêm dòng."
1. Bảng test này chính là bước "liệt kê mọi tổ hợp loại người dùng × tài nguyên × thao tác" mà Authorization Cheat Sheet yêu cầu ở pha thiết kế (Enforce Least Privileges), chuyển thành code. IDOR Cheat Sheet yêu cầu kiểm cho mọi thao tác: đọc, tạo, sửa, xoá, export và thao tác quản trị — ma trận nên có cột thao tác, không chỉ GET.
2. Docs Laravel ghi chú middleware CSRF tự tắt khi chạy test — nên test CSRF phải viết riêng (bật lại middleware) hoặc kiểm ở tầng e2e trên staging HTTPS.
6.3 SAST, DAST, pentest: phủ được gì
1. SAST (phân tích tĩnh) bắt tốt mẫu cú pháp: fetch URL từ input (SSRF Cheat Sheet dẫn bộ rule Semgrep cho SSRF),
ghép đường dẫn, $request->all() vào create, CORS origin: true. SAST không biết bài nộp 42 là của ai —
phần logic của A01 nằm ngoài tầm (Bài 0 mục 5.3 đã nói).
2. DAST (quét động) chỉ tìm được IDOR khi được cấu hình nhiều tài khoản và biết object nào của ai; quét một tài khoản thì không. OWASP Testing Guide có hẳn chương Authorization Testing; cách làm cốt lõi giống IDOR Cheat Sheet: tạo user A, B, mỗi người có object riêng, đăng nhập bằng A và thử object của B.
3. Review theo luồng dữ liệu vẫn là công cụ chính: với mỗi loại dữ liệu nhạy cảm, liệt kê mọi đường nó đi ra (API, trang, export, email, file, cache, GraphQL) và kiểm từng đường có cùng check không (CWE-424). Với GraphQL, phân quyền theo field có bẫy riêng — xem GraphQL bảo mật, mục 3.8 (cùng field, nhiều đường đi) và 3.7 (CSRF qua GET).
6.4 Log, metric, alert
1. Log mọi lần từ chối kèm user, ability, object, IP (4.8). OWASP prevention: "Log access control failures, alert admins when appropriate (e.g., repeated failures)". Authorization Cheat Sheet: định dạng nhất quán, parse được, đồng bộ đồng hồ, đưa về log tập trung/SIEM, không log dữ liệu nhạy cảm.
2. Tín hiệu đáng alert (ngưỡng là minh hoạ, phải hiệu chỉnh theo traffic thật): một user bị từ chối object-level nhiều lần trong thời gian ngắn (vd hơn 20 lần/5 phút); một user truy cập nhiều ID khác nhau liên tiếp trên cùng loại tài nguyên (dấu hiệu dò tuần tự — đếm số ID phân biệt, không đếm số request); tỉ lệ 404 trên route có tham số ID tăng đột biến; egress proxy ghi nhận kết nối tới dải nội bộ hoặc địa chỉ metadata (ở hệ thống đúng thiết kế, con số này phải bằng 0 — một lần cũng là sự kiện); 403/419 CSRF tăng vọt ngay sau deploy (thường là lỗi cấu hình proxy/HTTPS, không phải tấn công — nhưng phải biết để không tắt nhầm bảo vệ).
3. Rollout có kiểm soát. CSRF Cheat Sheet khuyên triển khai chính sách Fetch Metadata ở chế độ "log only" trước, theo dõi
user agent không gửi Sec-Fetch-*, ghi rõ danh sách endpoint được miễn, và thêm Vary: Sec-Fetch-Site, Origin nếu response
phụ thuộc các header đó (để cache/CDN không trả nhầm). Cùng tư duy áp được cho deny-by-default: bật chế độ chỉ log danh sách route sẽ bị
đóng (Nest @nestjs/authorization log sẵn lúc khởi động theo docs), xử lý xong mới bật chặn.
4. Khi phát hiện một lỗ IDOR đang ở production: vá (thêm điều kiện phạm vi), rồi trả lời câu "đã lộ bao nhiêu" — chỉ trả lời được nếu access log có user id + object id cho từng request. Đây là lý do log phải thiết kế từ trước chứ không thêm sau sự cố. Bài học First American (mục 8) là phần "báo lên trên": đội bảo mật đã biết lỗ hổng nhiều tháng mà lãnh đạo phụ trách công bố không được báo.
7. Áp dụng vào hệ thống trong CV
Nguồn: cv-v3.md. Dấu * = giả định, CV không nói. Hành vi Moodle đọc từ source nhánh MOODLE_405_STABLE; CV
không ghi LMS Uni đang chạy bản Moodle nào* (CV chỉ ghi đã từng nâng 2.7 → 3.5 LTS ở Topica), nên tên hàm/capability cần đối chiếu với
bản thật.
7.1 LMS Uni — Moodle + student portal NestJS dùng chung database
1. Moodle có sẵn một hệ access control theo context, và đó là thứ cần kể được khi phỏng vấn. Trong source 4.5:
require_login($courseorid, ...) (lib/moodlelib.php) xác thực và kiểm người dùng vào được khoá học;
has_capability($capability, context $context, $user = null, $doanything = true) và require_capability(...)
(lib/accesslib.php) kiểm một capability trong một context (site, khoá học, module); require_sesskey() /
confirm_sesskey() (lib/sessionlib.php) là cơ chế chống CSRF của Moodle cho thao tác đổi state; external function (web service)
gọi external_api::validate_context() rồi kiểm capability. Comment của has_capability ghi: khách và người chưa đăng nhập
không bao giờ có capability "nguy hiểm" (ghi, hoặc có rủi ro XSS/config/mất dữ liệu).
2. Tách dữ liệu sinh viên/giảng viên dựa trên capability + context. Trong mod/assign/db/access.php (4.5),
mod/assign:grade ở context module, archetype mặc định: teacher, editingteacher, manager. Trong
lib/db/access.php, moodle/site:accessallgroups mặc định chỉ cho editingteacher và manager — tức là giảng viên
không có quyền chỉnh sửa (non-editing teacher, thường là trợ giảng) trong khoá ở chế độ separate groups chỉ thấy nhóm của mình.
Đây là ví dụ cụ thể của "least privilege theo chiều ngang" mà Authorization Cheat Sheet nói tới.
3. Chỗ dễ thủng nhất: student portal. CV ghi portal NestJS "tách khỏi giao diện Moodle nhưng dùng chung database". Đọc thẳng bảng của Moodle thì không có lớp capability nào của Moodle chạy: mọi luật "sinh viên chỉ thấy điểm của mình", "giảng viên chỉ thấy khoá được phân công, và nếu là trợ giảng ở separate groups thì chỉ nhóm của mình" phải được viết lại trong NestJS*. Hai hướng, mỗi hướng một đánh đổi:
| Hướng | Ưu | Nhược |
|---|---|---|
| Portal gọi Moodle web service (Moodle tự kiểm capability) | Một nguồn sự thật cho phân quyền; override quyền ở từng context vẫn có hiệu lực | Chậm hơn, phụ thuộc Moodle — đi ngược lý do tách portal (CV: throughput 180 → 750 req/s) |
| Portal đọc DB trực tiếp, tự cài luật (hiện trạng theo CV*) | Nhanh, tối ưu query được | Luật nhân đôi; override quyền ở từng context, role tuỳ biến, group mode dễ bị bỏ sót; phải có test so khớp với Moodle |
Với hướng thứ hai, cách giữ an toàn: một module "scope" duy nhất trong NestJS sinh điều kiện phạm vi cho mọi query (như 4.1), guard
toàn cục deny-by-default (4.2), và một bộ contract test chạy định kỳ: với một mẫu user × khoá, so kết quả của portal với kết quả
has_capability phía Moodle*.
4. Link tải báo cáo xác thực bằng JWT (CV). Câu hỏi interviewer sẽ đào: token có gắn ID báo cáo và user không, hết hạn
sau bao lâu, có kiểm lại quyền lúc tải không*. Link trong email bị chuyển tiếp là bearer token — ai có link là tải được trong thời hạn.
Phương án chặt hơn: link chỉ dẫn tới trang đăng nhập, sau đăng nhập mới kiểm policy và trả temporaryUrl rất ngắn*.
5. Job xuất báo cáo trên worker (CV). Job chạy không có request, không có "user hiện tại"; nếu job query theo tham số lớp/khoá mà không mang theo người yêu cầu và áp lại phạm vi, file Excel có thể chứa điểm của lớp khác — đúng Case 1 trong bài ABAC*.
6. File trong moodledata trên NFS (CV). Moodle phục vụ file người dùng qua pluginfile.php, nơi kiểm quyền theo
context. Nếu vì hiệu năng mà Nginx được cấu hình phục vụ thẳng thư mục moodledata (thay vì cơ chế X-Accel-Redirect sau khi PHP kiểm
quyền) thì toàn bộ kiểm tra đó bị bỏ qua*. File trên S3 (CV) cũng vậy: bucket phải private, truy cập qua URL ký ngắn hạn*.
7. SSRF trong Moodle. Moodle có core\files\curl_security_helper (đọc source 4.5) với hai cấu hình quản trị:
curlsecurityblockedhosts (host/IP bị chặn) và curlsecurityallowedport (cổng được phép); helper phân giải host bằng
gethostbynamel để so. Các tính năng tải nội dung từ URL dùng lớp curl của Moodle*. Câu hỏi vận hành: hai cấu hình này
đang để gì, có ai xoá trắng không*; và nếu app node chạy trên AWS* (CV liệt kê AWS S3, không nói app chạy ở đâu) thì metadata service
đã chuyển IMDSv2 chưa*.
7.2 LMS Micro-learning — 4 trường, một codebase
1. Cô lập tenant là A01 cốt lõi. CV: "bộ codebase dùng chung" khi mở rộng từ 1 lên 4 trường. CV không nói tách dữ liệu
theo kiểu nào (cột school_id, schema riêng, hay database riêng)*. Nếu là cột chung bảng: global scope + test ma trận "user trường
A × dữ liệu trường B" cho mọi endpoint, và cân nhắc RLS trên PostgreSQL (CV có PostgreSQL) làm lớp chiều sâu (5.3)*.
2. Cache Redis. CV: cache hit ratio 93%. Mọi cache key chứa dữ liệu theo trường/user phải có tiền tố tenant/user*; nếu không, query đúng mà vẫn trả dữ liệu của trường khác từ cache. Cùng bẫy với cảnh báo "đừng cache theo URL trên route có check theo bản ghi" trong docs Nest.
3. Idempotency key (CV: Redis SET NX + unique constraint). Nếu key do client sinh và không gắn với user/tenant, hai
người dùng trùng key có thể nhận về response đã lưu của nhau*. Gắn phạm vi vào key (vd tenant:user:key) và vào unique constraint*.
4. MinIO (CV): bucket hoặc prefix theo trường, URL ký ngắn hạn sau khi policy cho phép*. MongoDB (CV): mọi query lọc theo trường*. Livewire: public property là input của client — xem bài Livewire.
7.3 Trả lời mẫu
Nếu được hỏi "Ở hệ thống của anh, A01 thể hiện ở đâu và anh xử lý thế nào?": "Rủi ro lớn nhất ở LMS Uni là student portal NestJS đọc chung database với Moodle, nên nó không được hưởng hệ capability theo context của Moodle. Mọi luật như sinh viên chỉ thấy điểm của mình, giảng viên chỉ thấy khoá và nhóm được phân công phải viết lại; tôi gom chúng vào một module sinh điều kiện phạm vi cho mọi query, guard toàn cục mặc định đóng, và test ma trận theo vai trò. Ở LMS Micro-learning, bốn trường dùng chung codebase nên cô lập tenant là trọng tâm: điều kiện trường học ở tầng query, cache key có tiền tố tenant, file trên MinIO qua link ký ngắn hạn." (Minato sửa các chi tiết có dấu * theo đúng hệ thống thật trước khi dùng câu này.)
8. Anti-patterns & bài học thực tế
8.1 Sự cố công khai
| Sự cố | Chuyện gì xảy ra (theo nguồn) | Dạng A01 | Bài học |
|---|---|---|---|
| First American Financial, 2019 | Krebs: website để lộ khoảng 885 triệu file liên quan giao dịch bất động sản từ 2003 (số tài khoản ngân hàng, số an sinh xã hội...), không cần xác thực; ai có URL của một tài liệu hợp lệ có thể xem tài liệu khác chỉ bằng cách đổi một chữ số trong link. SEC (2021): đội bảo mật đã phát hiện lỗ hổng nhiều tháng trước nhưng không khắc phục theo chính sách, lãnh đạo phụ trách công bố không được báo; phạt 487.616 USD. | IDOR trên tài liệu, ID tuần tự, thiếu cả authentication | Link "chỉ gửi cho đúng người" không phải là kiểm soát truy cập. Và quy trình: lỗ hổng đã biết phải có đường báo lên trên. |
| USPS Informed Visibility, 2018 | Krebs: một API cho phép bất kỳ user đã đăng nhập usps.com truy vấn chi tiết tài khoản của người dùng khác (email, địa chỉ, số điện thoại...), khoảng 60 triệu người dùng bị ảnh hưởng; nhiều tham số chấp nhận wildcard; người phát hiện báo hơn một năm không được phản hồi. | Thiếu object-level authorization ở API; lọc theo tham số do client điều khiển | "Đã đăng nhập" không phải là quyền. Tham số tìm kiếm cũng là tham chiếu tới object — phải có phạm vi. |
| Optus, 2022 | Theo hồ sơ ACMA nộp toà án Úc (2024, iTnews/techpartner tường thuật): API có access control nhưng một thay đổi code năm 2018 làm suy yếu nó; endpoint hướng Internet, "dormant and not in use"; lỗi được phát hiện và sửa năm 2021 trên site chính nhưng không trên API ở subdomain. Optus nói kẻ tấn công xoay qua hàng chục nghìn IP để né phát hiện. | Thiếu check trên đường đi thay thế (CWE-424); endpoint chết vẫn mở | Sửa một lỗ phải hỏi "đường nào khác dùng cùng logic?". Endpoint không dùng phải tắt. Rate limit theo IP không đủ. |
| Capital One, 2019 | Krebs: một WAF cấu hình sai bị lừa chuyển request tới dịch vụ metadata của AWS, lấy credential tạm của role gắn với máy; role đó được cấp quá nhiều quyền (liệt kê và đọc mọi file trong các bucket). SSRF không nằm trong bộ rule mặc định của WAF đó. | SSRF + least privilege hỏng ở IAM | SSRF nguy hiểm tỉ lệ với quyền của danh tính mà server mang. IMDSv2, egress, và least privilege cho role của máy quan trọng ngang code. |
8.2 Anti-pattern thường gặp khi review
1. "Frontend đã ẩn nút rồi." Kịch bản 3 của OWASP. Ẩn nút là UX, không phải kiểm soát (Bẫy 11 trong RBAC).
2. "Đổi sang UUID là hết IDOR." IDOR Cheat Sheet: vẫn phải kiểm quyền; UUID chỉ là lớp chiều sâu.
3. "Admin qua hết bằng Gate::before." Tiện, nhưng vượt luôn luật nghiệp vụ nằm trong policy; tài khoản admin bị chiếm
là mất tất cả. Cân nhắc giới hạn before theo ability, và đặt bất biến nghiệp vụ ở domain model.
4. "Endpoint export chỉ là tải file." Export thường gom dữ liệu nhiều bản ghi nhất và hay chạy qua job không có user context.
5. "Cache cho nhanh." Cache theo URL đặt trên lớp check (docs Nest cảnh báo), cache key thiếu tenant.
6. "Bật CORS cho SPA chạy được." Dấu sao + credentials, origin: true, regex không neo.
7. "API thì bỏ CSRF." Đúng với API dùng bearer token trong header; sai với API dùng cookie session (SPA + Sanctum).
8. "GET cho tiện." Mọi lớp CSRF hiện đại để GET đi qua.
9. "Chặn 127.0.0.1 là đủ chống SSRF." Deny-list chỉ một địa chỉ bỏ sót cả bảng dải của Cheat Sheet, IPv6, tên miền trỏ vào nội bộ, và redirect. Cheat Sheet: deny-list là "last resort".
10. "Kiểm tra tên miền rồi mới fetch." Kiểm lúc validate, kết nối lúc khác → DNS rebinding. Kiểm phải ở thời điểm kết nối.
11. "Quyền nằm trong JWT cho đỡ query." Quyền cũ sống tới khi token hết hạn.
12. "Policy đúng là đủ." Policy cho một bản ghi đúng nhưng query danh sách lệch luật — danh sách vẫn rò (ABAC Bẫy 1).
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Authentication khác authorization thế nào? Lỗi nào thuộc A01, lỗi nào thuộc A07? | Authentication: anh là ai (A07). Authorization: anh được làm gì với cái gì (A01). Đăng nhập đúng người mà xem được dữ liệu người khác là A01; mạo danh được người khác là A07. |
| IDOR và BOLA có khác nhau không? | Cùng một bản chất: thiếu kiểm tra quyền ở mức object khi tham chiếu do client gửi. IDOR là tên trong OWASP Top 10/Cheat Sheet; BOLA là tên trong API Security Top 10 (API1). CWE-639. |
| Đặt check quyền theo object ở controller, service hay DB? | Điều kiện phạm vi trong query ở service/repository để mọi đường đi (API, GraphQL, job) dùng chung; policy gắn ở route cho thao tác trên một bản ghi; RLS ở DB là lớp chiều sâu nếu dùng PostgreSQL. Controller một mình là chỗ dễ bị quên nhất. |
| 403 hay 404? | 401 chưa xác thực; 403 không được dùng chức năng; 404 khi bản ghi ngoài phạm vi và sự tồn tại là nhạy cảm/ID đoán được. Nhất quán trong cùng loại tài nguyên, và log phía server ghi rõ là từ chối. |
| Vì sao SSRF được gộp vào A01? | OWASP chỉ ghi là đã gộp (CWE-918 nằm trong A01). Cách hiểu của tôi: SSRF là server dùng quyền mạng của nó để truy cập thứ người gọi không có quyền — vẫn là hành động ngoài quyền, ở chiều request đi ra; cùng họ confused deputy với CSRF. |
| SameSite=Lax mặc định rồi, còn cần CSRF token không? | Thường vẫn cần một lớp chính: Lax để GET đi qua, tính theo registrable domain nên subdomain là same-site, trình duyệt cũ không áp dụng. Cheat Sheet chỉ cho SameSite đứng một mình khi thoả hết điều kiện. Laravel 13 và Nest 12.1 dùng Fetch Metadata (+ token fallback ở Laravel). |
| CORS có chống CSRF không? | Không. CORS quyết định ai đọc được response; form HTML vẫn gửi request "simple" tới mọi origin mà không cần preflight. Ngược lại, CORS sai (reflect origin + credentials) còn mở thêm đường đọc dữ liệu. |
| Chặn SSRF bằng cách kiểm IP thì sao vẫn bị vượt? | Kiểm tên miền chứ không kiểm IP; kiểm một IP trong nhiều IP; phân giải lần hai khi kết nối (DNS rebinding); follow redirect; parser đọc host khác nhau. Sửa: kiểm mọi IP tại thời điểm kết nối, tắt redirect, dựng lại URL, và có egress ở tầng mạng. |
| Gỡ quyền giảng viên nhưng họ vẫn sửa điểm được 30 phút, vì sao? | Quyền nằm trong claim JWT hoặc cache chưa invalidation. OWASP: JWT sống ngắn, refresh token + thu hồi; quyết định nhạy cảm đọc trạng thái hiện tại. Chi tiết 3 lớp cache trong bài RBAC. |
| Dùng policy engine ngoài (OPA) khi nào? | Khi nhiều service/ngôn ngữ cần cùng luật, hoặc cần audit luật tập trung. Đổi lại thêm latency, một điểm hỏng phải fail closed, và lọc danh sách khó. Một codebase, team nhỏ: policy trong app + test là đủ. |
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:
- Package
@nestjs/authorizationvàenableCsrfProtection(): mô tả theo docs NestJS hiện hành (docs CSRF ghi "Starting with NestJS v12.1"); chưa cài, chưa chạy, chưa xác định package authorization phát hành từ bản nào. Nguồn: docs.nestjs.com Security → Authorization, CSRF Protection. PreventRequestForgerycủa Laravel 13 (Sec-Fetch-Site rồi fallback token,originOnly,allowSameSite): theo docs CSRF 13.x và upgrade guide 13.0; chưa chạy request thật để xem mã 419/403.*+supports_credentials→ reflect origin: đọc sourcefruitcake/php-cors(nhánh master) vàHandleCors.php(Laravel 13.x); chưa gửi request thật qua một app Laravel 13.- Test kiến trúc duyệt route (4.2): chưa chạy; dạng trả về của
gatherMiddleware()(alias hay tên class) phụ thuộc cách đăng ký middleware. - Kiểu của
$resulttrongGate::afterkhi policy trảResponsethay vìbool: docs chỉ ghibool|null; chưa đọc sourceGate.php. FILTER_FLAG_GLOBAL_RANGE(PHP 8.2+) loại chính xác những dải nào, có trùng với bảng tối thiểu của SSRF Cheat Sheet không: chưa đối chiếu với tài liệu PHP hay source.CURLOPT_RESOLVEqua optioncurlcủa Guzzle/Laravel HTTP Client: chỉ hiệu lực với curl handler; cú pháp địa chỉ IPv6 trong ngoặc vuông; giữ đúng SNI/chứng chỉ — theo tài liệu curl được SSRF Cheat Sheet dẫn, chưa chạy.- Node
lookuptuỳ biến: việc Node gọilookupvớiall: truetrong cơ chế tự chọn họ địa chỉ, và việc bỏ qualookupkhi host là IP literal — theo hiểu biết về docs Node, chưa chạy;BlockListxử lý địa chỉ IPv4-mapped IPv6 thế nào chưa kiểm. - Flysystem: hành vi chuẩn hoá đường dẫn đọc từ
WhitespacePathNormalizer.phpnhánh 3.x; chưa xác nhận Laravel 13 dùng đúng normalizer này với cấu hình mặc định. - Moodle: tên hàm, capability và archetype đọc từ source
MOODLE_405_STABLE; bản Moodle thật của LMS Uni chưa biết*. Việc các tính năng tải URL của Moodle đi quacurl_security_helperlà suy luận, chưa đọc hết lớpcurl. - Các chi tiết có dấu * ở mục 7: giả định về hệ thống của Minato, cần Minato xác nhận.
- Sự cố ở mục 8: theo báo chí điều tra (KrebsOnSecurity, iTnews) và văn bản cơ quan quản lý (SEC, hồ sơ ACMA); chi tiết kỹ thuật Optus là cáo buộc trong vụ kiện, Optus tuyên bố sẽ phản bác.
Nguồn
- OWASP Top 10:2025 — A01 Broken Access Control: top10.owasp.org (bảng điểm, mô tả, prevention, 3 kịch bản, 40 CWE)
- OWASP Authorization Cheat Sheet: cheatsheetseries.owasp.org
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet: cheatsheetseries.owasp.org
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet: cheatsheetseries.owasp.org
- OWASP Server-Side Request Forgery Prevention Cheat Sheet: cheatsheetseries.owasp.org
- Laravel 13.x docs: Authorization (Gate, Policy,
denyAsNotFound,before/after, middlewarecan), Routing (scoped bindings, CORS), Eloquent (Mass Assignment), File Storage, HTTP Client (globalOptions,allow_redirects), CSRF Protection, Upgrade Guide 13.0 (Request Forgery Protection), Sanctum - Laravel framework source nhánh 13.x:
config/cors.php,config/session.php,src/Illuminate/Http/Middleware/HandleCors.php,src/Illuminate/Database/Eloquent/Concerns/GuardsAttributes.php— github.com/laravel/framework - fruitcake/php-cors
src/CorsService.php: github.com/fruitcake/php-cors - thephpleague/flysystem 3.x
src/WhitespacePathNormalizer.php: github.com/thephpleague/flysystem - NestJS docs: Guards, Passport recipe (Enable authentication globally,
@Public()), Authorization (@nestjs/authorization, production checklist), CORS, CSRF Protection, Validation (content/application/validation.md) - expressjs/cors README (option
origin): github.com/expressjs/cors - Moodle source
MOODLE_405_STABLE:lib/accesslib.php,lib/moodlelib.php,lib/sessionlib.php,lib/db/access.php,mod/assign/db/access.php,lib/external/classes/external_api.php,lib/classes/files/curl_security_helper.php— github.com/moodle/moodle - SEC, "SEC Charges Issuer With Cybersecurity Disclosure Controls Failures" (2021-102, First American): sec.gov
- KrebsOnSecurity, "First American Financial Corp. Leaked Hundreds of Millions of Title Insurance Records" (05/2019): krebsonsecurity.com
- KrebsOnSecurity, "USPS Site Exposed Data on 60 Million Users" (11/2018): krebsonsecurity.com
- KrebsOnSecurity, "What We Can Learn from the Capital One Hack" (08/2019): krebsonsecurity.com
- "Optus breach allegedly enabled by access control coding error" (iTnews, đăng lại trên techpartner.news, 06/2024; dẫn hồ sơ ACMA tại Federal Court of Australia): techpartner.news
- Stripe Smokescreen (egress proxy mã nguồn mở, ví dụ cho lớp mạng): github.com/stripe/smokescreen
- Trong repo: Bài 0 — tổng quan, RBAC, ABAC, GraphQL — bảo mật và giới hạn query, Livewire public property,
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 · Bài 1 — A01 Broken Access Control (bài này) · Tiếp: Bài 2 — A02 Security Misconfiguration →