Bài 2 — A02:2025 Security Misconfiguration: debug, mặc định nguy hiểm, header, cookie, XXE và drift cấu hình
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 1 — A01 Broken Access Control · Bài 2 — A02 Security Misconfiguration (bài này) · Tiếp: Bài 3 — A03 Software Supply Chain Failures →
Security Misconfiguration (cấu hình sai về mặt bảo mật) là mục nhảy hạng mạnh nhất của bản 2025: từ hạng 5 năm 2021
lên hạng 2. Điểm khó của mục này khi phỏng vấn là nó không có một dòng code sai để chỉ vào. Code có thể hoàn hảo, nhưng
một biến môi trường APP_DEBUG=true sót lại, một root của Nginx trỏ nhầm lên thư mục cha, một bucket đặt chế độ
public, một cookie phiên thiếu cờ Secure vì app không biết mình đang đứng sau load balancer — là đủ để lộ dữ liệu. Câu
trả lời mà interviewer cho Senior/Tech Lead muốn nghe không phải là "nhớ tắt debug", mà là làm thế nào để cấu hình sai
không thể lên production mà không ai biết: cấu hình nằm trong code, được kiểm ở CI, được app tự kiểm khi khởi động, và
được dò lại từ bên ngoài sau khi deploy.
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 đọ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. "A02 là khi hệ thống được dựng hoặc cấu hình sai về mặt bảo mật, dù code không có bug: debug bật trên production, tài khoản và trang mẫu mặc định chưa gỡ, web server phục vụ cả file cấu hình, thiếu security header, cookie phiên thiếu cờ, parser XML còn đọc entity ngoài, bucket để public. Gốc rễ là cấu hình nằm rải rác ngoài code, khác nhau giữa các môi trường và các node, không ai review. Cách tôi xử lý: cấu hình là code và được review, mặc định của mọi thứ phải là an toàn, app tự kiểm cấu hình lúc khởi động và từ chối chạy nếu sai, sau deploy có một bước dò từ bên ngoài, và có cơ chế phát hiện drift giữa các node."
1. Mục này là gì trong 2025
1.1 Định nghĩa của OWASP và tám dấu hiệu
Trang A02:2025 định nghĩa ngắn: "Security misconfiguration is when a system, application, or cloud service is set up
incorrectly from a security perspective, creating vulnerabilities." Phạm vi rất rộng — "system, application, or cloud
service" — tức là từ php.ini, file cấu hình Nginx, .env của Laravel, main.ts của NestJS cho tới quyền của bucket
S3/MinIO đều thuộc mục này. OWASP liệt kê tám dấu hiệu cho thấy ứng dụng có thể bị ảnh hưởng (dịch sát nghĩa):
- Thiếu hardening (gia cố — tắt bớt, siết lại) ở bất kỳ phần nào của stack, hoặc phân quyền cloud service sai.
- Bật tính năng không cần thiết: port, service, trang, tài khoản, testing framework, đặc quyền thừa.
- Tài khoản mặc định và mật khẩu mặc định vẫn còn và chưa đổi.
- Không có cấu hình tập trung để chặn thông báo lỗi quá chi tiết: xử lý lỗi làm lộ stack trace hoặc thông tin thừa cho người dùng.
- Hệ thống đã nâng cấp nhưng tính năng bảo mật mới bị tắt hoặc cấu hình không an toàn.
- Ưu tiên tương thích ngược quá mức dẫn tới cấu hình không an toàn ("Excessive prioritization of backward compatibility" — điểm mới đáng chú ý so với 2021).
- Thiết lập bảo mật trong application server, framework, thư viện, database không đặt về giá trị an toàn.
- Server không gửi security header hoặc gửi với giá trị không an toàn.
Và một câu kết cần nhớ nguyên văn vì nó là luận điểm của cả bài: "Without a concerted, repeatable application security configuration hardening process, systems are at a higher risk." — rủi ro không nằm ở một setting cụ thể, mà ở việc không có quy trình lặp lại được để đặt và kiểm các setting đó.
1.2 Thay đổi so với 2021
1. Thứ hạng: 5 → 2. Lời giải thích của OWASP: "With more shifts into highly configurable software, it's not surprising to see this category moving up." Phần mềm ngày càng được điều khiển bằng cấu hình (feature flag, container orchestration, cloud IAM, framework có hàng trăm option), nên không gian để cấu hình sai lớn lên tương ứng.
2. Tỉ lệ ứng dụng dính lỗi: 90% → 100%. Năm 2021 viết "90% of applications were tested for some form of misconfiguration"; năm 2025 viết "100% of the applications tested were found to have some form of misconfiguration". Số lần xuất hiện CWE tăng từ 208.387 lên 719.084, số CVE từ 789 lên 1.375. Lưu ý khi diễn giải: một phần tăng là do dữ liệu đóng góp nhiều hơn và công cụ quét tự động phát hiện loại lỗi này rất dễ (thiếu một header là một "occurrence"), nên con số lớn không có nghĩa là mỗi lỗi đều nghiêm trọng — đây là lý do Avg Weighted Impact của A02 chỉ 3,97, thấp hơn hẳn A01.
3. Danh sách CWE: 20 → 16. So hai trang chính thức: bản 2025 bỏ 6 CWE (CWE-2 7PK Environment, CWE-520 .NET Impersonation, CWE-537 Java Runtime Error Message Containing Sensitive Information, CWE-541 Sensitive Information in an Include File, CWE-756 Missing Custom Error Page, CWE-1032 nhóm A6 của bản 2017) và thêm 2 CWE (CWE-5 J2EE Misconfiguration: Data Transmission Without Encryption, CWE-489 Active Debug Code). Việc thêm CWE-489 "debug code còn chạy" khớp với thực tế mà bài này sẽ nhấn mạnh: debug mode và dev tool sót lên production là nhóm lỗi phổ biến nhất. Việc bỏ CWE-537/756 (thông báo lỗi lộ thông tin, thiếu trang lỗi tuỳ biến) đi cùng với việc bản 2025 tạo mục mới A10 Mishandling of Exceptional Conditions — xem phân biệt ở mục 1.5.
1.3 Bảng điểm chính thức
| Chỉ số | A05:2021 | A02:2025 | Đọc thế nào |
|---|---|---|---|
| CWE mapped | 20 | 16 | Gọn lại, bỏ các CWE gắn với công nghệ cũ, thêm CWE-489 Active Debug Code |
| Max incidence rate | 19,84% | 27,70% | Có ứng dụng mà gần 28% số điểm được test dính một CWE của nhóm |
| Avg incidence rate | 4,51% | 3,00% | Trung bình thấp vì lỗi rải mỏng trên nhiều ứng dụng |
| Max / Avg coverage | 89,58% / 44,84% | 100% / 52,35% | Công cụ test phủ được nhóm này rất tốt — dễ phát hiện tự động |
| Avg weighted exploit | 8,12 | 7,96 | Dễ khai thác (thang 10) |
| Avg weighted impact | 6,56 | 3,97 | Tác động trung bình thấp — nhiều occurrence là thiếu header, lộ version |
| Total occurrences | 208.387 | 719.084 | Gấp ~3,5 lần |
| Total CVEs | 789 | 1.375 | CVE gắn với CWE của nhóm (ví dụ XXE trong một thư viện) |
Cặp số "exploit cao, impact trung bình thấp" là chi tiết đáng nói khi phỏng vấn: đa số lỗi cấu hình riêng lẻ không tự
gây thảm hoạ (thiếu X-Content-Type-Options không làm lộ database), nhưng chúng rất dễ bị dò ra bằng quét tự động,
và một số ít trong đó (debug mode, bucket public, dev tool lộ ra ngoài) có impact cực lớn. Chính vì phân bố đuôi dài này mà
cách phòng thủ hiệu quả là tự động hoá kiểm tra toàn bộ, thay vì chọn vài lỗi "quan trọng" để nhớ.
1.4 Gom 16 CWE theo cơ chế
| Cơ chế | CWE | Ví dụ trong stack Laravel / NestJS / Nginx |
|---|---|---|
| Cấu hình chung, setting do bên ngoài điều khiển | CWE-16 Configuration, CWE-15 External Control of System or Configuration Setting | Header Host/X-Forwarded-* được tin mà không qua proxy tin cậy; tham số request đổi được setting |
| Debug, chế độ phát triển còn chạy | CWE-489 Active Debug Code, CWE-11 ASP.NET Creating Debug Binary | APP_DEBUG=true, Telescope/Swagger/GraphiQL mở trên production |
| Bí mật nằm trong cấu hình, code, biến môi trường | CWE-260 Password in Configuration File, CWE-13 ASP.NET Password in Configuration File, CWE-547 Hard-coded Security-relevant Constants, CWE-526 Exposure via Environmental Variables | .env commit vào git hoặc phục vụ ra web; debug page in toàn bộ biến môi trường |
| Cookie thiếu thuộc tính bảo vệ | CWE-614 Missing 'Secure', CWE-1004 Missing 'HttpOnly', CWE-315 Cleartext Sensitive Info in Cookie | Session cookie không có Secure khi TLS kết thúc ở load balancer |
| Chính sách cross-domain quá rộng | CWE-942 Permissive Cross-domain Policy | CORS * kèm credentials — đã đi sâu ở Bài 1 mục 3.5/4.5 |
| Parser XML mặc định không an toàn | CWE-611 XXE, CWE-776 XML Entity Expansion | Import file XML (SCORM, đề thi, đồng bộ SIS) bằng parser bật entity substitution |
| Truyền dữ liệu không mã hoá | CWE-5 J2EE Data Transmission Without Encryption | Tương đương ở stack khác: thiếu HSTS, kết nối nội bộ tới DB/Redis không TLS — phần mật mã thuộc Bài 4 |
| Validation framework bị tắt | CWE-1174 ASP.NET Improper Model Validation | Tương đương ở Nest: không bật ValidationPipe toàn cục hoặc tắt whitelist — xem Bài 1 mục 4.3 |
Bảng cho thấy một điều quan trọng: vài CWE mang tên công nghệ cụ thể (ASP.NET, J2EE) nhưng cơ chế của chúng có mặt ở mọi stack. Khi phỏng vấn ở công ty PHP/Node, đừng bỏ qua chúng vì "tôi không dùng .NET"; hãy dịch sang tương đương trong stack của mình như cột thứ ba.
1.5 Ranh giới với A01, A03, A04, A10
A02 chồng lấn với nhiều mục khác, và interviewer hay thử xem ứng viên phân loại có nhất quán không. Nguyên tắc của bản 2025 (đã nói ở Bài 0) là xếp theo root cause (nguyên nhân gốc), không theo triệu chứng:
- Debug bật làm lộ stack trace → A02 (cấu hình sai). Code tự bắt exception rồi in
$e->getMessage()ra response → A10 Mishandling of Exceptional Conditions (code xử lý sai) — xem Bài 10. - Bucket để public → A02. API kiểm sai quyền của từng file → A01.
- Không vá phiên bản có CVE → A03 Software Supply Chain Failures. Đã vá nhưng tính năng bảo mật mới bị tắt → A02 (dấu hiệu số 5 ở trên).
- Thiếu HSTS → A02 (không gửi security header). Dùng TLS 1.0 hoặc thuật toán yếu → A04 Cryptographic Failures.
Lưu ý thực dụng: bảng phân loại chỉ quan trọng khi viết báo cáo hoặc ánh xạ sang checklist. Trong review code thật, câu hỏi đúng là "lỗi này có thể lặp lại ở chỗ khác không, và cái gì trong quy trình đã để nó lọt" — câu đó dẫn tới biện pháp mang tính hệ thống, việc dán nhãn A02 hay A10 thì không.
2. Bài toán gốc — vì sao lỗi cấu hình không bao giờ hết
1. Mặc định được tối ưu cho "chạy được ngay", không cho "an toàn trên production". Mọi framework đều phải giải
một bài toán mâu thuẫn: người mới cài phải thấy ứng dụng chạy và thấy lỗi chi tiết để học, còn production phải im lặng và
khoá chặt. Laravel giải bằng cách để config/app.php mặc định an toàn — 'env' => env('APP_ENV', 'production') và
'debug' => (bool) env('APP_DEBUG', false) (đọc từ source skeleton) — nhưng file mẫu .env.example lại đặt
APP_ENV=local và APP_DEBUG=true. Hệ quả: nếu quy trình deploy là "copy .env.example thành .env rồi sửa vài
dòng", người deploy phải nhớ sửa đúng hai dòng đó. Mặc định an toàn của file config bị file mẫu ghi đè. Đây là hình
mẫu chung: an toàn phụ thuộc vào trí nhớ con người ở đúng một bước thủ công.
2. Cấu hình nằm ngoài vòng review. Code đi qua merge request, có reviewer, có test. Cấu hình thì nằm rải rác: một
phần trong repo (config/*.php, main.ts), một phần trong file .env chỉ có trên server, một phần trong
/etc/nginx, php.ini, một phần trong console của cloud. Phần ngoài repo thường được sửa tay bằng SSH, không có
lịch sử, không có reviewer. Theo OWASP Infrastructure as Code Security Cheat Sheet, chính việc đưa hạ tầng thành code
("allows the configuration and deployment of infrastructure components faster with consistency") là cách kéo phần
này vào vòng review.
3. Cấu hình phụ thuộc lẫn nhau theo cách không ai nhìn thấy. Ví dụ cụ thể sẽ phân tích ở mục 3.5: trong Laravel,
cờ Secure của session cookie mặc định là null, nghĩa là "tự quyết theo request hiện tại có phải HTTPS không". Câu
hỏi "request có phải HTTPS không" lại phụ thuộc vào cấu hình TrustProxies khi TLS kết thúc ở load balancer. Một setting
về proxy — mà người cấu hình nghĩ chỉ liên quan tới việc sinh URL — âm thầm quyết định cookie phiên có bị gửi qua HTTP hay
không. Tương tự, Telescope chỉ bật gate phân quyền khi môi trường không phải local; đặt nhầm APP_ENV=local trên
production là mở dashboard Telescope cho mọi người (docs Telescope cảnh báo đúng câu này).
4. Môi trường và node trôi lệch theo thời gian (configuration drift). Hệ thống có 3 app node sau load balancer: hotfix lúc 2 giờ sáng chỉ sửa trên node 2; một package hệ thống được nâng trên node 1 nhưng không trên node 3. Sau vài tháng, ba node "giống nhau" có ba cấu hình khác nhau, và scanner chạy vào lúc load balancer trỏ tới node 1 sẽ không thấy lỗi của node 2. OWASP đưa thẳng vào phần prevention: "Development, QA, and production environments should all be configured identically, with different credentials used in each environment. This process should be automated."
5. Tương thích ngược được ưu tiên hơn an toàn. Dấu hiệu số 6 của OWASP. Khi nâng cấp, tính năng bảo mật mới thường
được giao kèm cờ tắt để không làm vỡ hệ thống cũ, và cờ tắt đó ở lại mãi. Ví dụ quen thuộc: bật lại TLS 1.0 vì một client cũ,
để SameSite=None cho toàn bộ cookie vì một iframe tích hợp, giữ supports_credentials + wildcard origin vì "trước giờ
vẫn chạy". Mỗi ngoại lệ là hợp lý tại thời điểm thêm vào; vấn đề là không ai có nhiệm vụ gỡ chúng.
6. Chi phí của lỗi không rơi vào người gây ra lỗi. Người bật debug để điều tra sự cố thì giải quyết xong sự cố; cái giá (lộ biến môi trường) rơi vào lúc khác, cho người khác. Không có phản hồi tức thì nên không có học hỏi. Đây là lý do biện pháp phải là máy kiểm tra — app từ chối khởi động, CI từ chối merge, scanner báo đỏ — chứ không phải checklist dựa vào ý thức.
flowchart LR
A["Mặc định tối ưu cho dev<br/>(.env.example: debug=true)"] --> E["Cấu hình sai<br/>lên production"]
B["Cấu hình nằm ngoài repo<br/>(sửa tay qua SSH, console cloud)"] --> E
C["Setting phụ thuộc ngầm<br/>(TrustProxies → cờ Secure)"] --> E
D["Drift giữa môi trường, giữa node"] --> E
F["Ngoại lệ tương thích ngược<br/>không ai gỡ"] --> E
E --> G["Dễ dò bằng quét tự động (exploit 7,96)<br/>đa số impact nhỏ, vài lỗi cực lớn<br/>(debug, bucket public, dev tool)"]
Cách nói khi phỏng vấn: "Lỗi cấu hình không hết vì nó sinh ra từ cách hệ thống được vận hành chứ không từ một dòng code. Mặc định của framework được làm cho dễ bắt đầu, cấu hình nằm ngoài vòng review, các setting ảnh hưởng nhau một cách ngầm, các node lệch nhau theo thời gian, và các ngoại lệ tương thích ngược không ai dọn. Vì vậy tôi không trông vào việc mọi người nhớ checklist, mà đẩy kiểm tra cho máy: CI, kiểm tra lúc khởi động, và quét sau deploy."
3. Cơ chế — mỗi dạng cấu hình sai sai ở giả định nào
Mỗi tiểu mục dưới đây trả lời ba câu: giả định nào trong cách dựng hệ thống bị sai, hệ quả là gì, và ví dụ của OWASP hoặc của framework. Mẫu code sai/đúng cụ thể nằm ở mục 4.
3.1 Tính năng, tài khoản, trang mẫu thừa còn bật
1. Giả định sai: "cái gì không dùng thì không ai biết mà gọi". Thực tế mọi đường dẫn phổ biến (trang quản trị mặc định, dashboard của công cụ, file tài liệu đi kèm) đều được scanner thử theo danh sách có sẵn; việc không công bố một URL không phải là kiểm soát truy cập.
2. Kịch bản #1 của OWASP: application server được giao kèm các ứng dụng mẫu, không gỡ khỏi production; các ứng dụng mẫu có lỗ hổng đã biết; nếu một trong số đó là admin console và tài khoản mặc định chưa đổi thì người ngoài đăng nhập bằng mật khẩu mặc định và chiếm quyền. Chuỗi hỏng gồm hai cấu hình sai chồng lên nhau: tính năng thừa và tài khoản mặc định.
3. Phiên bản hiện đại trong stack PHP/Node không phải là "sample app của Tomcat" mà là các công cụ phát triển:
Laravel Telescope (ghi lại request, query, exception, job — tức là chứa dữ liệu thật của người dùng), Horizon dashboard,
Swagger UI của NestJS, GraphiQL/Apollo Sandbox, trang phpinfo() ai đó tạo để kiểm tra extension, Adminer/phpMyAdmin cài
"tạm". Docs Laravel tách rõ: Telescope nên cài với --dev nếu chỉ dùng ở local, và dashboard của cả Telescope lẫn Horizon
"By default, you will only be able to access this dashboard in the local environment", còn ở môi trường khác thì phải
qua gate viewTelescope/viewHorizon.
4. Ví dụ có CVE thật: CVE-2021-3129 — theo NVD, Ignition (trang lỗi debug của Laravel) trước 2.5.2 cho phép người ngoài chưa xác thực thực thi mã từ xa, "exploitable on sites using debug mode with Laravel before 8.4.2"; CVE này nằm trong danh mục Known Exploited Vulnerabilities của CISA (thêm ngày 18/09/2023), tức là đã bị khai thác thật trên diện rộng. Bài học không phải là chi tiết lỗ hổng mà là điều kiện tiên quyết: chỉ các site bật debug mode mới bị. Một cấu hình đúng (debug tắt) vô hiệu hoá cả một lớp lỗ hổng trong công cụ debug, kể cả lỗ hổng chưa được biết tới.
flowchart TD
A["Request tới đường dẫn của công cụ dev<br/>(/telescope, /horizon, /api-docs)"] --> B{"Package có được cài<br/>trong image production?"}
B -- "Không (cài --dev)" --> X["404 — không có gì để gọi"]
B -- "Có" --> C{"Provider có được đăng ký<br/>ở môi trường này?"}
C -- "Không" --> X
C -- "Có" --> D{"Gate / auth của dashboard<br/>cho user này qua?"}
D -- "Không" --> Y["403"]
D -- "Có" --> Z["Dashboard (chỉ cho người được phép)"]
Diagram trên đọc theo nguyên tắc "A minimal platform without any unnecessary features" của OWASP: lớp mạnh nhất là lớp
đầu tiên — không có code thì không có lỗ. Gate ở lớp cuối là cần thiết nhưng là lớp yếu nhất, vì nó phụ thuộc vào
APP_ENV đặt đúng (mục 3.3 dưới).
3.2 Lỗi hiển thị quá chi tiết
1. Giả định sai: "thông báo lỗi chi tiết giúp người dùng hiểu chuyện gì xảy ra". Người dùng thật không cần stack trace; người cần nó là kẻ đang trinh sát. Error Handling Cheat Sheet mở đầu bằng đúng ý này: "an attack always begins with a Reconnaissance phase", và lỗi không xử lý giúp ích cho pha trinh sát — lộ tên và phiên bản framework, đường dẫn cài đặt, câu SQL lỗi (gợi ý điểm có thể injection).
2. Kịch bản #3 của OWASP: cấu hình application server cho phép trả thông báo lỗi chi tiết như stack trace về cho người dùng, làm lộ thông tin nhạy cảm hoặc lỗi bên dưới, chẳng hạn phiên bản thành phần đã biết là có lỗ hổng.
3. Trong Laravel, ranh giới nằm ở một cờ. Đọc source Illuminate/Foundation/Exceptions/Handler.php nhánh 13.x,
hàm convertExceptionToArray() (dùng khi render JSON): nếu config('app.debug') là true thì trả message,
exception (tên class), file, line và toàn bộ trace; nếu false thì chỉ trả message — là message của
exception nếu đó là HTTP exception, còn lại là chuỗi cố định 'Server Error'. Docs Laravel nói thẳng hậu quả của việc bật
debug trên production: "you risk exposing sensitive configuration values to your application's end users".
4. Chi tiết tinh tế: HTTP exception vẫn mang message ra ngoài. Cùng file đó, prepareException() chuyển
ModelNotFoundException thành NotFoundHttpException($e->getMessage(), $e). Vì nó đã là HTTP exception, khi debug tắt
thì nhánh isHttpException vẫn trả message gốc — message của ModelNotFoundException thường chứa tên class model và
ID được tìm. Lộ App\Models\Submission không phải thảm hoạ, nhưng là thông tin trinh sát miễn phí và là ví dụ cho việc
"tắt debug" chưa phải là "không lộ gì". (Đọc source, chưa chạy request thật — xem Chưa verify.) Docs Laravel có sẵn ví dụ
override đúng exception này cho api/* để trả 'Record not found.'.
5. Trong NestJS, mặc định an toàn hơn. Source packages/core/exceptions/base-exception-filter.ts: exception
không phải HttpException được trả về dạng { statusCode: 500, message: 'Internal server error' } (hằng
UNKNOWN_EXCEPTION_MESSAGE). Rủi ro ở Nest đến từ phía ngược lại: dev viết một global filter "bắt tất cả" để định dạng lỗi
cho đẹp, và trong đó trả luôn exception.message hoặc exception.stack — tự tay phá mặc định an toàn của framework.
flowchart TD
A["Exception xảy ra trong handler"] --> B["Log đầy đủ phía server<br/>(stack, context, request id)"]
A --> C{"Là lỗi nghiệp vụ / HTTP có chủ đích?<br/>(422 validation, 404, 403)"}
C -- "Có" --> D["Trả message đã được thiết kế<br/>cho người dùng đọc"]
C -- "Không (lỗi bất ngờ)" --> E["Trả thông báo chung + request id<br/>(không class, không file, không SQL)"]
B --> F["Người vận hành tra log bằng request id"]
E --> F
Đây đúng là mô hình mục tiêu của Error Handling Cheat Sheet: "when an unexpected error occurs then a generic response is returned by the application but the error details are logged server side for investigation, and not returned to the user". Request id là sợi dây nối response chung chung với log chi tiết — thiếu nó thì đội vận hành sẽ đòi bật lại debug để điều tra, và vòng lặp quay lại từ đầu.
3.3 Biến môi trường quyết định quá nhiều — và mặc định fail-open
1. Giả định sai: "biến môi trường chỉ chứa giá trị, không chứa quyết định bảo mật". Thực tế một biến như
APP_ENV hay NODE_ENV bật/tắt cả cụm hành vi: gate của Telescope/Horizon (chỉ áp dụng ngoài local), GraphiQL
của Apollo driver (docs NestJS: "By default, GraphiQL is only enabled when NODE_ENV is not production"), mức chi tiết của
log, việc nạp provider dev.
2. Fail-open vs fail-closed. Hãy so hai kiểu mặc định khi biến bị thiếu:
- Laravel
config/app.php: thiếuAPP_ENV→'production'; thiếuAPP_DEBUG→false. Thiếu biến thì rơi về trạng thái an toàn — fail-closed (đóng khi lỗi). - Ví dụ trong docs
@nestjs/config:NODE_ENV: z.enum([...]).default('development'). Thiếu biến thì rơi vềdevelopment— nếu code dựa vàoNODE_ENV !== 'production'để bật Swagger hay GraphiQL thì quên đặt biến là mở công cụ dev — fail-open (mở khi lỗi). Ví dụ trong docs là để minh hoạ cú pháp, không sai; nhưng chép nguyên vào production là chép luôn một mặc định fail-open.
3. Ví dụ số minh hoạ. Giả sử một đội deploy 3 môi trường × 2 app (Laravel + Nest) = 6 file env, mỗi file 40 biến
= 240 giá trị. Chỉ cần 1% giá trị bị đặt nhầm là 2–3 lỗi, và xác suất một trong số đó trúng một biến "quyết định" như
APP_ENV không nhỏ. Kiểm tra thủ công 240 giá trị mỗi lần deploy là không khả thi; schema validation lúc khởi động (mục
4.1) thì kiểm cả 240 trong vài mili giây.
flowchart TD
A["Biến APP_ENV / NODE_ENV"] --> B{"Có giá trị?"}
B -- "Thiếu" --> C{"Mặc định của code"}
C -- "production (fail-closed)" --> D["Debug tắt, gate bật, công cụ dev tắt"]
C -- "development (fail-open)" --> E["Swagger, GraphiQL, log chi tiết bật trên production"]
B -- "Có nhưng sai (local trên prod)" --> F["Gate Telescope bỏ qua — dashboard mở"]
B -- "production" --> D
G["Schema validation lúc boot:<br/>bắt buộc có, không mặc định, đúng tập giá trị"] -.-> B
3.4 Web server phục vụ sai thư mục, liệt kê thư mục, lộ file ẩn
1. Giả định sai: "web server chỉ phục vụ những gì app cho phép". Thực tế web server phục vụ mọi file nằm dưới
root mà không có rule chặn, độc lập với routing của app. Nếu root trỏ vào thư mục gốc project thay vì public/,
file .env, composer.json, storage/logs/laravel.log đều có URL. Docs Laravel (Deployment) cảnh báo nguyên
văn: "You should never attempt to move the index.php file to your project's root, as serving the application from the
project root will expose many sensitive configuration files to the public Internet".
2. Kịch bản #2 của OWASP: directory listing không bị tắt; người ngoài liệt kê được thư mục, tải về file class đã biên dịch, dịch ngược ra source và từ đó tìm được một lỗi access control nghiêm trọng. Điểm đáng chú ý: lỗi cấu hình ở đây không phải là lỗ hổng cuối cùng — nó là bước trinh sát mở đường cho một lỗi A01. Nhiều lỗi A02 có vai trò "khuếch đại" như vậy.
3. Các file "ăn theo" hay bị quên: file backup do editor/thao tác tay tạo ra (bản copy .bak, .old,
~), thư mục .git (lộ toàn bộ lịch sử source, kể cả secret từng commit), vendor/ và node_modules/ nếu
nằm dưới web root, file dump SQL để "tạm" trong public/. Moodle là ví dụ tốt về cách một sản phẩm tự phòng: check
publicpaths trong báo cáo Security overview (source lib/classes/check/environment/publicpaths.php, nhánh
MOODLE_405_STABLE) tự gửi request vào chính site và mong đợi 404 cho các mẫu đường dẫn như /vendor/,
/node_modules/, file bắt đầu bằng dấu chấm, composer.json, file .lock, db/install.xml,
upgrade.txt, phpunit.xml, thư mục fixtures và behat.
4. Lộ phiên bản qua header. Nginx mặc định server_tokens on (docs ngx_http_core_module), tức là header
Server và trang lỗi của Nginx có số phiên bản; PHP thêm header X-Powered-By khi expose_php bật. HTTP Headers
Cheat Sheet khuyên gỡ hoặc đặt giá trị không mang thông tin, và ghi chú công bằng: "Remember that attackers have other means
of fingerprinting the server technology". Tức là gỡ version giúp giảm nhiễu từ scanner tự động nhắm theo version, không
phải là lớp phòng thủ chính.
flowchart TD
A["GET /duong-dan-bat-ky"] --> B{"Nginx: khớp location chặn?<br/>(file ẩn, đuôi backup)"}
B -- "Có" --> X["403 / 404"]
B -- "Không" --> C{"File có thật dưới root = public/?"}
C -- "Có (css, js, ảnh)" --> D["Trả file tĩnh"]
C -- "Không" --> E["try_files → index.php → router của app"]
E --> F{"Route tồn tại?"}
F -- "Không" --> X
F -- "Có" --> G["Middleware, policy, handler"]
3.5 Cookie thiếu cờ, và cái bẫy TLS kết thúc ở load balancer
1. Ba CWE của nhóm cookie: CWE-614 (thiếu Secure — cookie có thể đi qua kết nối HTTP thường), CWE-1004 (thiếu
HttpOnly — JavaScript đọc được cookie, nên một lỗi XSS biến thành chiếm phiên), CWE-315 (lưu thông tin nhạy cảm dạng rõ
trong cookie). Thuộc tính SameSite liên quan tới CSRF đã được phân tích ở Bài 1
mục 3.6 — bài này không lặp lại.
2. Mặc định của Laravel (đọc từ config/session.php của skeleton): 'http_only' => env('SESSION_HTTP_ONLY',
true), 'same_site' => env('SESSION_SAME_SITE', 'lax'), nhưng 'secure' => env('SESSION_SECURE_COOKIE') —
không có giá trị mặc định, tức là null nếu không đặt.
3. null nghĩa là gì — đi theo source. Middleware StartSession (Laravel 13.x) tạo
Symfony\Component\HttpFoundation\Cookie với $config['secure'] truyền nguyên. Trong Symfony
Cookie::isSecure() trả $this->secure ?? $this->secureDefault, và Response::prepare() chỉ gọi
setSecureDefault(true) cho mọi cookie khi $request->isSecure() là true. Vậy với secure = null, cookie
phiên có cờ Secure khi và chỉ khi app nhận ra request hiện tại là HTTPS.
4. Và app nhận ra HTTPS bằng cách nào khi đứng sau load balancer? Khi TLS kết thúc ở load balancer, kết nối từ LB tới
app là HTTP thường. App chỉ biết request gốc là HTTPS nhờ header X-Forwarded-Proto, và Laravel chỉ tin header này khi IP
gửi tới nằm trong danh sách trustProxies. Docs Laravel mô tả triệu chứng phổ biến nhất của việc thiếu cấu hình này —
"your application sometimes does not generate HTTPS links" — nhưng không nhắc tới hệ quả thứ hai: cookie phiên mất cờ
Secure. Chuỗi suy luận ở đây ghép từ ba file source; chưa chạy request thật để quan sát header Set-Cookie (xem
Chưa verify).
5. Ví dụ số. LMS có 3 app node sau 1 Nginx LB kết thúc TLS. Dev đặt SESSION_SECURE_COOKIE trống (theo
mặc định), không cấu hình trustProxies vì "link vẫn ra HTTPS do APP_URL là https" (Laravel dùng APP_URL cho
một số chỗ, nhưng isSecure() của request thì không đọc APP_URL). Kết quả: 100% cookie phiên phát ra không có cờ
Secure. Có HSTS thì trình duyệt không bao giờ đi HTTP tới domain này nên rủi ro thực tế giảm nhiều; thiếu HSTS thì một
lần người dùng gõ địa chỉ không có https:// trên Wi-Fi công cộng là cookie đi dạng rõ trước khi bị redirect. Hai cấu
hình (cờ cookie, HSTS) che cho nhau — và cũng vì thế mà thiếu một cái thường không ai phát hiện.
6. NestJS/Express có bẫy ngược chiều. README của express-session: nếu đặt secure: true mà truy cập
qua HTTP thì "the cookie will not be set", và "If you have your node.js behind a proxy and are using secure: true,
you need to set 'trust proxy' in express". Tức là ở Express, cấu hình proxy sai làm đăng nhập hỏng (cookie không được
set) thay vì làm cookie kém an toàn. Hỏng ồn ào thì người ta phát hiện ngay — nhưng cách "sửa" phổ biến nhất mà dev tìm được
trên mạng là đổi thành secure: false, biến một lỗi cấu hình ồn ào thành một lỗi im lặng.
flowchart TD
A["Trình duyệt —HTTPS→ Nginx LB<br/>(kết thúc TLS)"] --> B["LB —HTTP + X-Forwarded-Proto: https→ app node"]
B --> C{"IP của LB nằm trong trustProxies?"}
C -- "Có" --> D["request isSecure() = true"]
C -- "Không" --> E["request isSecure() = false"]
D --> F{"session.secure"}
E --> F
F -- "true (đặt tường minh)" --> G["Set-Cookie có Secure"]
F -- "null + isSecure true" --> G
F -- "null + isSecure false" --> H["Set-Cookie KHÔNG có Secure<br/>(im lặng, không lỗi)"]
3.6 Thiếu security header, hoặc header đặt sai chỗ
1. Giả định sai: "bảo mật là việc của server, trình duyệt chỉ hiển thị". Thực tế trình duyệt có sẵn nhiều lớp bảo vệ — chặn trang bị nhúng vào iframe của site khác (clickjacking), không đoán MIME type, chỉ đi HTTPS, chặn script lạ — nhưng phần lớn chỉ bật khi server yêu cầu bằng header. Không gửi header là bỏ phí lớp phòng thủ miễn phí.
2. Bộ tối thiểu theo HTTP Headers Cheat Sheet (giá trị khuyến nghị nguyên văn):
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload— kèm cảnh báo: đặt thời hạn dài mà chứng chỉ hết hạn hoặc lỗi thì người dùng hợp lệ không vào được site cho tới khi hết thời hạn. Nên tăngmax-agedần và chỉ xinpreloadkhi chắc chắn mọi subdomain đều HTTPS.X-Content-Type-Options: nosniff.- Chống nhúng iframe: ưu tiên
Content-Security-Policy: frame-ancestors,X-Frame-Options: DENYcho trình duyệt cũ. Cheat sheet ghi rõ header này vô nghĩa với response JSON hoặc redirect — chỉ có ích cho trang có thứ để bấm. Referrer-Policy: strict-origin-when-cross-origin.- CSP: "complex to configure and maintain" — có cheat sheet riêng; với REST API trả JSON thì gần như vô nghĩa.
X-XSS-Protection: không đặt hoặc đặt0— header cũ này có thể tự tạo lỗ XSS trên trình duyệt cũ. Đây là ví dụ điển hình của checklist lỗi thời: nhiều bài viết cũ vẫn khuyên đặt1; mode=block.- Gỡ
X-Powered-By, đặtServerkhông mang thông tin. - Dữ liệu nhạy cảm:
Cache-Control: no-store; cheat sheet nhắcno-cachekhông ngăn lưu cache, chỉ bắt kiểm tra lại.
3. Bẫy kế thừa của Nginx. Docs ngx_http_headers_module: các chỉ thị add_header "are inherited from
the previous configuration level if and only if there are no add_header directives defined on the current level". Hệ
quả cụ thể: đặt 5 security header ở khối server, rồi thêm một add_header Cache-Control ... trong
location /build/ — mọi response từ /build/ mất cả 5 header kia, im lặng. Docs mới bổ sung chỉ thị
add_header_inherit (từ 1.29.3) cho phép chọn merge; với bản cũ hơn thì phải lặp lại header hoặc dùng
include một file chung. Ngoài ra, thiếu tham số always thì header chỉ được thêm cho một số mã trạng thái (2xx,
3xx) — trang lỗi 4xx/5xx không có header.
4. Đặt header ở đâu: Nginx hay app? Đây là câu hỏi thiết kế có trade-off, phân tích ở mục 4.4 và 5.2. Tóm tắt:
Nginx phủ cả file tĩnh và trang lỗi do chính Nginx sinh ra; app biết ngữ cảnh từng route (CSP khác nhau cho trang admin và trang
học, nonce theo request). Điều tệ nhất là cả hai cùng đặt một header với hai giá trị khác nhau — trình duyệt nhận hai header
Content-Security-Policy sẽ áp dụng cả hai (giao của hai policy), gây lỗi khó hiểu.
flowchart TD
A["Response từ app"] --> B["Nginx thêm header chung<br/>(HSTS, nosniff, Referrer-Policy) — always"]
C["File tĩnh / trang lỗi của Nginx"] --> B
A --> D["App thêm header theo ngữ cảnh<br/>(CSP theo route, Cache-Control no-store cho dữ liệu cá nhân)"]
B --> E{"Mỗi header chỉ có MỘT nơi sở hữu?"}
D --> E
E -- "Có" --> F["Trình duyệt áp dụng đúng một policy"]
E -- "Không" --> G["Header trùng / lệch giá trị<br/>(CSP bị giao, HSTS hai giá trị)"]
3.7 Parser XML mặc định không an toàn — XXE
1. Giả định sai: "XML chỉ là dữ liệu". Đặc tả XML có DTD (khai báo kiểu tài liệu), trong DTD có thể khai báo entity (một tên đại diện cho một đoạn nội dung), và entity có thể là external — nội dung được lấy từ một URI. Parser nào thay thế entity và tải external entity thì đang để tài liệu do người ngoài gửi lên quyết định server đọc gì và gọi tới đâu. XXE Prevention Cheat Sheet tóm tắt hậu quả: lộ file cục bộ, SSRF (server bị mượn để gửi request — đã gộp vào A01, xem Bài 1 mục 3.7), hoặc cạn tài nguyên. CWE-776 là biến thể thứ hai: entity lồng nhau đệ quy làm một tài liệu nhỏ nở ra hàng GB khi parse.
2. Vì sao xếp vào A02 chứ không phải Injection? Vì lỗi nằm ở cấu hình của parser, không ở code của ứng dụng.
Cùng một dòng simplexml_load_string($xml) an toàn hay không tuỳ vào các cờ truyền vào và phiên bản libxml2.
3. Trạng thái mặc định hiện nay (theo cheat sheet): với PHP, "With libxml 2.9.0 or later, entity substitution is
disabled by default. Do not enable LIBXML_NOENT, LIBXML_DTDLOAD, or LIBXML_DTDVALID for untrusted XML without explicitly
blocking external resources". Nghĩa là trên PHP hiện đại, XXE gần như luôn là do ai đó bật cờ — thường để "sửa" một
file XML hợp lệ có dùng entity không hiển thị đúng. Cheat sheet còn chỉ ra một bẫy: truyền null trực tiếp cho
libxml_set_external_entity_loader() không tương đương với callback trả về null, và không chặn được việc tải
entity ngoài.
4. Nguyên tắc chung: "Disable document type definitions (DTDs) whenever possible. Reject DOCTYPE declarations if the parser supports it." Và: "Treat an unsupported security setting as a configuration failure. Do not continue processing untrusted XML with partial hardening." — câu này đáng nhớ vì nó áp dụng cho mọi cấu hình bảo mật, không riêng XML: nếu không đặt được setting an toàn thì dừng, không chạy tiếp với nửa vời.
flowchart TD
A["File XML từ người dùng / đối tác<br/>(SCORM manifest, đề thi, đồng bộ SIS)"] --> B{"Giới hạn kích thước OK?"}
B -- "Không" --> R["Từ chối 413"]
B -- "Có" --> C["Parse với cờ an toàn<br/>(không NOENT, không DTDLOAD, NONET)<br/>+ loader entity ngoài trả null"]
C --> D{"Tài liệu có DOCTYPE?"}
D -- "Có" --> R2["Từ chối 422 + log"]
D -- "Không" --> E["Validate theo schema nghiệp vụ"]
E --> F["Xử lý"]
3.8 Quyền cloud storage mặc định hoặc chia sẻ quá rộng
1. Kịch bản #4 của OWASP: nhà cung cấp cloud mặc định để quyền chia sẻ mở ra Internet, làm dữ liệu nhạy cảm trong cloud storage bị truy cập. Phần prevention thêm: "Review cloud storage permissions (e.g., S3 bucket permissions)" và dùng "identity federation, short-lived credentials, or role-based access mechanisms provided by the underlying platform instead of embedding static keys or secrets in code, configuration files, or pipelines".
2. Ba kiểu hỏng khác nhau, hay bị gộp làm một:
- Bucket/thư mục public toàn bộ — ai biết tên là liệt kê và tải được. Kiểu dễ phát hiện nhất bằng scanner.
- Link chia sẻ có chữ ký nhưng phạm vi quá rộng hoặc sống quá lâu — vụ Microsoft 2023 (mục 8) là ví dụ: một link dành để chia sẻ model AI lại có quyền trên cả storage account, quyền "full control" thay vì chỉ đọc, và hạn tới năm 2051.
- Object public đơn lẻ trong bucket private — do code upload đặt
visibilitypublic cho tiện hiển thị ảnh, rồi cùng code đó được dùng cho file bài nộp.
3. Với stack trong CV (MinIO / AWS S3): mặc định nên là private cho mọi thứ, tải về qua URL ký có hạn ngắn (Laravel
Storage::temporaryUrl(), AWS SDK getSignedUrl() — README của @aws-sdk/s3-request-presigner ghi
expiresIn mặc định 900 giây nếu không đặt), và chỉ asset thật sự công khai (ảnh khoá học, CSS build) mới ở bucket public
riêng. Tách bucket theo mức độ công khai rẻ hơn nhiều so với kiểm quyền từng object.
flowchart TD
A["Người dùng xin tải file bài nộp"] --> B["App kiểm quyền object-level (A01)"]
B -- "Không được" --> X["404"]
B -- "Được" --> C["App ký URL: 1 object, chỉ đọc, hết hạn sau vài phút"]
C --> D["Trình duyệt tải trực tiếp từ MinIO / S3"]
E["Bucket bài nộp: private, không policy anonymous"] --> D
F["Bucket asset công khai: tách riêng"] -.-> G["Ảnh khoá học, CSS, JS"]
3.9 Bí mật nằm trong cấu hình và drift giữa các môi trường
1. Bí mật (CWE-260, CWE-547, CWE-526). Mật khẩu DB trong file config commit vào git; API key hard-code trong source;
debug page in ra toàn bộ $_ENV. Ba dạng cùng một gốc: secret đi theo đường của code/config thay vì đường riêng có kiểm
soát truy cập và có thể xoay vòng. Phần quản lý khoá (KMS, xoay vòng) thuộc Bài 4;
phần secret lọt vào pipeline CI thuộc Bài 3. Ở góc A02, điều cần nhớ là
debug mode biến mọi secret trong biến môi trường thành dữ liệu công khai — đó là lý do docs Laravel nói "sensitive
configuration values".
2. Drift. Cấu hình đúng lúc deploy chưa chắc còn đúng sau ba tháng. Nguồn drift phổ biến: sửa tay qua SSH khi xử lý sự cố, nâng package hệ điều hành không đồng đều, một node được dựng lại từ snapshot cũ, ai đó bật debug để điều tra rồi quên tắt. OWASP IaC Cheat Sheet gợi ý immutable infrastructure (hạ tầng bất biến — không sửa node đang chạy, muốn đổi thì dựng node mới từ spec): "No deviation, no changes. If a change to a specification is required, then a whole new set of infrastructure is provisioned". Với hệ thống dùng Docker image build một lần cho cả ba môi trường (như LMS Micro-learning trong CV), phần lớn drift ở tầng app bị loại bỏ — phần còn lại nằm ở file env, cấu hình Nginx LB và tầng hạ tầng ngoài container.
3.10 Hình động: một thay đổi cấu hình đi qua năm lớp kiểm soát
Gom các cơ chế phòng thủ ở trên thành một luồng: mỗi thay đổi cấu hình phải qua năm lớp — được review như code, được CI kiểm theo policy, được app tự kiểm khi khởi động, được dò từ bên ngoài sau deploy, và được so với trạng thái chuẩn định kỳ để bắt drift. Hình dưới cho ba thay đổi đi qua năm lớp đó.
APP_DEBUG=true — file này không nằm trong repo nên lớp
review và CI không thấy; lớp kiểm tra lúc khởi động từ chối chạy, môi trường mới không nhận traffic, bản cũ tiếp tục phục vụ.
Chấm tím (C): một lần sửa tay Nginx trên node 2 lúc xử lý sự cố — không đi qua pipeline nên chỉ có lớp drift (và lớp dò từ
ngoài, nếu lần dò trúng node 2) bắt được.Cách nói khi phỏng vấn: "Tôi nghĩ về cấu hình như một luồng có năm chốt. Hai chốt đầu là review và CI, rẻ nhưng chỉ thấy những gì nằm trong repo. Chốt thứ ba là app tự kiểm biến môi trường khi khởi động và từ chối chạy nếu sai, chốt này bắt được file env chỉ có trên server. Chốt thứ tư là dò từ bên ngoài sau deploy, thấy hệ thống như kẻ tấn công thấy. Chốt cuối là so định kỳ với bản chuẩn, để bắt những thay đổi tay không đi qua pipeline."
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, docs NestJS hiện hành (bản npm mới nhất lúc viết: @nestjs/core 12.2.0) và
source nhánh 13.x của laravel/framework; liệt kê ở Nguồn. Địa chỉ IP, tên domain, tên role là minh hoạ.
4.1 Môi trường: kiểm cấu hình trước khi nhận traffic
## ===== SAI: deploy bằng cách copy file mẫu rồi sửa tay vài dòng =====
cp .env.example .env # APP_ENV=local, APP_DEBUG=true đi thẳng lên production
sed -i 's/^DB_HOST=.*/DB_HOST=10.0.2.5/' .env
php artisan config:cache
// ===== ĐÚNG: app/Console/Commands/SecurityPreflight.php =====
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SecurityPreflight extends Command
{
protected $signature = 'security:preflight';
protected $description = 'Kiểm cấu hình bảo mật trước khi môi trường mới nhận traffic';
public function handle(): int
{
$errors = [];
if (! app()->isProduction()) $errors[] = 'APP_ENV phải là production';
if (config('app.debug')) $errors[] = 'APP_DEBUG phải là false';
if (blank(config('app.key'))) $errors[] = 'APP_KEY trống';
if (! str_starts_with((string) config('app.url'), 'https://'))
$errors[] = 'APP_URL phải là https';
if (config('session.secure') !== true) $errors[] = 'SESSION_SECURE_COOKIE phải đặt tường minh = true';
if (config('session.http_only') !== true) $errors[] = 'SESSION_HTTP_ONLY phải là true';
if (class_exists(\Laravel\Telescope\Telescope::class))
$errors[] = 'Telescope có trong image production (cài --dev?)';
foreach ($errors as $error) {
$this->error($error);
}
return $errors ? self::FAILURE : self::SUCCESS;
}
}
## ===== Bước deploy blue-green: preflight là cổng trước khi đổi upstream =====
docker compose run --rm app-green php artisan security:preflight \
&& ./healthcheck.sh green \
&& ./switch-upstream.sh green
Cách nói khi phỏng vấn: "Tôi không để việc tắt debug phụ thuộc vào trí nhớ của người deploy. App có một lệnh tự kiểm cấu hình bảo mật: môi trường phải là production, debug phải tắt, URL phải là https, cookie phiên phải đặt cờ Secure tường minh, công cụ dev không được có trong image. Lệnh này chạy trên môi trường mới trước khi load balancer chuyển traffic sang; sai một điều là dừng, bản cũ vẫn phục vụ bình thường."
Giải thích mẫu đúng:
1. Vì sao kiểm ở lúc deploy chứ không ở CI. File .env production thường không nằm trong repo (đúng như vậy —
nó chứa secret), nên CI không thấy nó. Thời điểm sớm nhất mà cấu hình thật xuất hiện là khi container của môi trường mới khởi
động với file env thật. Đặt preflight ngay sau bước đó và trước bước đổi upstream biến lỗi cấu hình thành "deploy thất
bại" thay vì "sự cố bảo mật". Với blue-green (như CV), chi phí của một lần thất bại gần như bằng 0: môi trường cũ không bị đụng
tới.
2. Vì sao không ném exception trong AppServiceProvider::boot(). Cách đó cũng "fail fast", nhưng nó làm mọi
request trả 500 và làm hỏng cả lệnh artisan (kể cả lệnh sửa lỗi). Một lệnh riêng có exit code rõ ràng thì pipeline đọc được,
log được, và không ảnh hưởng gì nếu chạy nhầm ở local. Trade-off: lệnh riêng chỉ có tác dụng nếu pipeline thật sự gọi nó —
phải có trong script deploy, không được là bước "tuỳ chọn".
3. config('session.secure') !== true chứ không phải ! config(...). Mục 3.5 đã chỉ ra null là giá
trị nguy hiểm vì nó uỷ quyết định cho việc phát hiện HTTPS. So sánh chặt với true bắt cả trường hợp biến bị bỏ trống. Hàm
env() của Laravel chuyển chuỗi true trong file env thành boolean, nên so sánh chặt hoạt động như mong đợi.
4. Lệnh tự đăng ký. Từ Laravel 11, lệnh đặt trong app/Console/Commands được nạp tự động, không cần khai báo ở
Kernel. self::FAILURE/self::SUCCESS là hằng của Symfony\Component\Console\Command\Command mà lớp Command của
Laravel kế thừa.
5. Điều lệnh này không làm được: nó không thấy cấu hình Nginx, php.ini, quyền bucket, cũng không thấy các node
khác. Nó là lớp 3 trong Hình 1, không thay được lớp 4 (dò từ ngoài) và lớp 5 (drift).
// ===== SAI: mặc định fail-open, công cụ dev suy ra từ tên môi trường =====
ConfigModule.forRoot({
validationSchema: z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), // thiếu biến → development
}),
});
if (process.env.NODE_ENV !== 'production') {
SwaggerModule.setup('api', app, documentFactory); // quên đặt NODE_ENV = mở tài liệu API trên production
}
// ===== ĐÚNG: env.schema.ts =====
import { z } from 'zod';
const bool = z.enum(['true', 'false']).transform((v) => v === 'true');
export const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'test', 'production']), // bắt buộc, KHÔNG có default
APP_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
COOKIE_SECURE: bool, // bắt buộc đặt tường minh
ENABLE_API_DOCS: bool.default('false'), // tính năng dev: opt-in, mặc định tắt
})
.superRefine((env, ctx) => {
if (env.NODE_ENV !== 'production') return;
if (env.ENABLE_API_DOCS)
ctx.addIssue({ code: 'custom', path: ['ENABLE_API_DOCS'], message: 'không bật API docs trên production' });
if (!env.COOKIE_SECURE)
ctx.addIssue({ code: 'custom', path: ['COOKIE_SECURE'], message: 'production phải dùng cookie Secure' });
if (!env.APP_URL.startsWith('https://'))
ctx.addIssue({ code: 'custom', path: ['APP_URL'], message: 'APP_URL phải là https' });
});
// app.module.ts
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true, validationSchema: envSchema })],
})
export class AppModule {}
Cách nói khi phỏng vấn: "Ở NestJS tôi khai báo một schema cho toàn bộ biến môi trường, và ứng dụng không khởi động được nếu schema không qua. Biến quyết định như tên môi trường thì bắt buộc và không có giá trị mặc định, để quên đặt là lỗi ngay chứ không âm thầm rơi về chế độ phát triển. Tính năng dành cho dev như tài liệu API thì phải bật tường minh bằng một cờ riêng, và schema cấm bật cờ đó trên production."
Giải thích mẫu đúng:
1. Validation chạy lúc bootstrap. Docs @nestjs/config (Schema validation): validationSchema nhận schema
theo chuẩn Standard Schema (Zod, Valibot, ArkType; Joi từ v18), và "The validation step then throws an exception during
bootstrap if the variable is missing". Mặc định mọi lỗi được báo cùng lúc (không dừng ở lỗi đầu) — tiện khi một file env
sai nhiều chỗ. Docs cũng nói ConfigService trả về giá trị sau transform của schema, nên ENABLE_API_DOCS đọc ra là
boolean chứ không phải chuỗi 'false' (chuỗi 'false' là truthy trong JavaScript — một nguồn lỗi kinh điển).
2. Tách "tên môi trường" khỏi "bật tính năng". Code if (NODE_ENV !== 'production') ghép hai quyết định khác nhau
vào một biến, nên một biến sai làm sai cả hai. Cờ riêng ENABLE_API_DOCS mặc định tắt có nghĩa là mọi sai sót (quên đặt,
gõ nhầm tên biến) đều rơi về trạng thái an toàn. Docs NestJS phần security headers cũng dùng cách này cho GraphiQL nhưng theo
chiều ngược lại (bật khi không phải production) — đó là mặc định của Apollo driver, không phải khuyến nghị cho code của bạn.
3. Bẫy: biến không khai báo vẫn đọc được. Docs ghi "Variables that the schema doesn't declare remain available
through ConfigService as well". Tức là code đọc một biến bảo mật mà quên thêm vào schema thì không được kiểm. Quy ước review:
mọi biến có ảnh hưởng bảo mật phải có trong schema; có thể thêm lint cấm process.env.X ngoài file config.
4. Đối chiếu với Laravel. Laravel không có schema validation cho env sẵn; lệnh preflight ở trên là bản "tự làm".
Ngược lại, file config/app.php của Laravel đã fail-closed sẵn cho APP_ENV/APP_DEBUG, còn ví dụ trong docs Nest
lại minh hoạ default development. Không framework nào an toàn toàn diện theo mặc định; phải biết mặc định của framework
mình đang dùng nằm ở chiều nào.
flowchart TD
A["Build image một lần<br/>(composer install --no-dev / npm ci --omit=dev)"] --> B["Khởi động môi trường green<br/>với file env thật"]
B --> C{"Schema env / security:preflight"}
C -- "Sai" --> D["Dừng deploy, báo lỗi rõ biến nào<br/>blue vẫn phục vụ"]
C -- "Đúng" --> E{"Healthcheck"}
E -- "Sai" --> D
E -- "Đúng" --> F["Đổi upstream Nginx sang green"]
F --> G["Dò từ ngoài sau deploy (mục 6.4)"]
4.2 Lỗi: thông báo chung ra ngoài, chi tiết vào log, nối bằng request id
// ===== SAI: tự bắt exception rồi đẩy chi tiết ra response =====
try {
$result = $this->grading->grade($submission);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()], 500);
}
// ===== ĐÚNG: bootstrap/app.php — xử lý tập trung =====
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions): void {
// Mọi log exception mang theo request id do Nginx sinh ra
$exceptions->context(fn () => [
'request_id' => request()->header('X-Request-Id'),
]);
// 404 của API: không để message gốc (tên model, ID) đi ra ngoài
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Not found.'], 404);
}
});
})
// app/Http/Middleware/EchoRequestId.php — trả request id cho client để báo lỗi kèm mã
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Request-Id', (string) $request->header('X-Request-Id'));
return $response;
}
Cách nói khi phỏng vấn: "Tôi không cho từng controller tự bắt lỗi rồi trả chi tiết. Lỗi được xử lý ở một chỗ: người dùng chỉ nhận thông báo chung kèm một mã request, còn log phía server có đầy đủ stack trace và cùng mã request đó. Khi người dùng báo lỗi, đội vận hành tra log theo mã, không cần bật debug trên production. Tôi còn ghi đè riêng lỗi không tìm thấy của API, vì mặc định thông báo của nó có thể chứa tên model."
Giải thích mẫu đúng:
1. Mặc định của Laravel đã đúng cho lỗi 500 khi debug tắt: chuỗi 'Server Error' (mục 3.2). Phần cần thêm là
request id — nếu không, mỗi lần có sự cố người ta sẽ muốn bật debug để xem lỗi, và đó chính là con đường đưa debug lên
production. Nginx có biến $request_id (chuỗi ngẫu nhiên cho mỗi request) để truyền xuống PHP-FPM qua
fastcgi_param HTTP_X_REQUEST_ID $request_id; (cấu hình ở 4.3) và ghi vào access log.
2. $exceptions->context() (docs Errors → Global Log Context) gắn dữ liệu vào mọi log exception; Laravel tự
thêm user ID nếu có. $exceptions->render() với NotFoundHttpException là ví dụ có sẵn trong docs; closure
không trả gì thì Laravel dùng render mặc định — nên route web vẫn ra trang 404 bình thường.
3. Mẫu sai là A10 hay A02? Theo cách phân loại ở mục 1.5, khối catch đẩy message ra ngoài là A10 (code xử lý
sai). Nó được đặt ở đây vì nó vô hiệu hoá cấu hình tập trung: dù APP_DEBUG=false, response vẫn có trace. Khi review,
grep getTraceAsString, getMessage() trong response là cách nhanh để tìm loại này.
// ===== SAI: filter "bắt tất cả" tự trả message và stack =====
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
host.switchToHttp().getResponse()
.status(500).json({ message: exception.message, stack: exception.stack });
}
}
// ===== ĐÚNG: kế thừa filter mặc định, chỉ thêm log =====
import { ArgumentsHost, Catch, HttpException, Logger } from '@nestjs/common';
import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core';
@Catch()
export class LoggedExceptionsFilter extends BaseExceptionFilter {
private readonly logger = new Logger('Exceptions');
catch(exception: unknown, host: ArgumentsHost) {
const req = host.switchToHttp().getRequest();
if (!(exception instanceof HttpException)) {
this.logger.error(
`request_id=${req.headers['x-request-id']} path=${req.url}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
super.catch(exception, host); // body mặc định: { statusCode: 500, message: 'Internal server error' }
}
}
// main.ts
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new LoggedExceptionsFilter(httpAdapter));
Cách nói khi phỏng vấn: "NestJS mặc định đã trả thông báo chung cho lỗi không lường trước. Rủi ro là chính team viết một filter bắt mọi lỗi để định dạng response, rồi đẩy luôn message và stack ra ngoài. Cách của tôi là kế thừa filter mặc định của framework, chỉ chen thêm bước ghi log có mã request, rồi giao lại cho filter mặc định quyết định body trả về."
Giải thích mẫu đúng:
1. Kế thừa thay vì viết lại. Docs NestJS (Exception filters → Inheritance) hướng dẫn đúng mẫu này: kế thừa
BaseExceptionFilter, gọi super.catch(); filter toàn cục cần truyền HttpAdapter vào constructor (hoặc đăng ký qua
token APP_FILTER). Lợi ích bảo mật: body của response do code của framework quyết định — đã được nhiều người review —
thay vì code tự viết.
2. Chỉ log lỗi không phải HttpException: 404, 403, 422 là luồng bình thường, log stack của chúng chỉ tạo
nhiễu. Lỗi bất ngờ mới cần stack. (Log các lần bị từ chối quyền là việc khác — xem Bài 1 mục 4.8 và
Bài 9.)
3. Lưu ý thứ tự filter: docs cảnh báo khi kết hợp filter bắt tất cả với filter cho kiểu exception cụ thể, phải khai báo filter bắt tất cả trước để filter cụ thể vẫn xử lý được loại của nó.
flowchart TD
A["Nginx sinh request_id"] --> B["App xử lý request"]
B -- "Exception bất ngờ" --> C["Log: request_id + stack + user id"]
B -- "Exception bất ngờ" --> D["Response 500: thông báo chung<br/>+ header X-Request-Id"]
B -- "HTTP exception có chủ đích" --> E["Response 404/403/422<br/>message đã thiết kế"]
D --> F["Người dùng báo lỗi kèm mã"]
F --> G["Vận hành tra log theo mã<br/>— không cần bật debug"]
C --> G
4.3 Web server: root ở public/, chỉ index.php được chạy, chặn file ẩn
# ===== SAI =====
server {
root /srv/lms; # gốc project: .env, storage/, vendor/ đều có URL
autoindex on; # liệt kê thư mục
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ { # MỌI file .php đều được chạy, kể cả file upload lọt vào
fastcgi_pass php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
# ===== ĐÚNG (khung theo docs Laravel → Deployment, thêm các dòng có chú thích) =====
http {
server_tokens off; # mặc định là on: lộ version trong header Server và trang lỗi
}
server {
listen 443 ssl;
server_name lms.example.edu;
root /srv/lms/current/public; # CHỈ thư mục public/
index index.php;
charset utf-8;
include snippets/security-headers.conf; # xem 4.4 — một file chung, include ở mọi khối có add_header
location / {
try_files $uri $uri/ /index.php?$query_string;
}
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass php_upstream;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param HTTP_X_REQUEST_ID $request_id; # nối log Nginx với log app (4.2)
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ \.php$ { return 404; } # mọi file .php khác: không chạy
location ~ /\.(?!well-known).* { deny all; } # file/thư mục ẩn (.env, .git)
location ~* \.(bak|old|orig|swp|sql|log)$ { deny all; } # file backup, dump, log bị bỏ quên
}
Cách nói khi phỏng vấn: "Cấu hình Nginx của tôi theo mẫu của docs Laravel: thư mục gốc của web là thư mục public chứ không phải thư mục project, nên file cấu hình và log không có đường dẫn nào trỏ tới. Chỉ đúng một file index.php được chuyển cho PHP chạy, mọi file PHP khác trả 404, file ẩn và file backup bị chặn. Phiên bản Nginx và header của PHP được tắt, nhưng tôi coi đó là giảm nhiễu chứ không phải lớp bảo vệ chính."
Giải thích mẫu đúng:
1. root ở public/ là dòng quan trọng nhất; mọi dòng còn lại là lớp phòng hờ nếu có file nhạy cảm bị
đặt nhầm vào public/. Docs Laravel giải thích lý do bằng chính câu đã trích ở mục 3.4.
2. Chỉ index.php được chạy. Mẫu sai dùng location ~ \.php$: nếu một file .php nào đó lọt vào
thư mục web (ví dụ thư mục upload nằm dưới public/ và kiểm tra đuôi file có lỗ), Nginx sẽ giao nó cho PHP-FPM chạy. Mẫu
đúng của docs Laravel chỉ khớp ^/index\.php(/|$); dòng location ~ \.php$ { return 404; } thêm vào làm ý đồ này
tường minh. Nginx kiểm tra các location dạng regex theo thứ tự xuất hiện, nên khối index.php phải đứng trước.
3. $realpath_root thay vì $document_root (docs Laravel dùng $realpath_root): khi deploy bằng
symlink current → releases/42, $realpath_root là đường dẫn đã phân giải symlink, nên PHP-FPM và OPcache thấy đúng bản
release mới. Chi tiết vận hành này nằm ở các bài chiến lược deployment; ở góc bảo
mật, nó tránh tình trạng "nửa cũ nửa mới" khi đổi release.
4. Thiếu gì: autoindex mặc định off nên chỉ cần không bật. server_tokens off và
fastcgi_hide_header X-Powered-By giảm thông tin lộ ra; cheat sheet tự nhắc chúng không ngăn được fingerprint bằng cách
khác. Đặt expose_php = Off trong php.ini cho cả các đường không đi qua Nginx (CLI, health endpoint nội bộ).
// ===== SAI: NestJS phục vụ static từ thư mục cha của dist =====
app.useStaticAssets(join(__dirname, '..')); // dist/.. = gốc project: .env, package.json đều tải được
// ===== ĐÚNG =====
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(__dirname, '..', 'public'), {
dotfiles: 'deny', // đặt tường minh, không dựa vào mặc định của serve-static
index: false, // không tự trả index.html của thư mục
});
Cách nói khi phỏng vấn: "Ở NestJS vấn đề tương tự xảy ra khi phục vụ file tĩnh: trỏ nhầm lên thư mục cha của bản build là đưa cả file cấu hình ra ngoài. Tôi chỉ phục vụ một thư mục public riêng, chặn file ẩn và tắt trả trang mục lục thư mục. Trong hệ thống thật tôi thích để Nginx hoặc CDN phục vụ file tĩnh, app Node chỉ trả API."
flowchart TD
A["Request tới Nginx"] --> B{"Regex location theo thứ tự"}
B -- "^/index.php" --> C["PHP-FPM chạy index.php<br/>+ X-Request-Id"]
B -- "*.php khác" --> X["404"]
B -- "file ẩn / backup / dump" --> Y["403"]
B -- "không khớp regex" --> D["location / : try_files"]
D -- "file tĩnh có thật trong public/" --> E["Trả file"]
D -- "không có" --> C
4.4 Security header: mỗi header một chủ sở hữu
# ===== SAI: header đặt ở server, rồi một location thêm add_header khác =====
server {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
add_header X-Content-Type-Options "nosniff";
location /build/ {
add_header Cache-Control "public, max-age=31536000, immutable"; # khối này MẤT hai header ở trên
}
}
# ===== ĐÚNG: snippets/security-headers.conf — include ở server VÀ ở mọi location có add_header =====
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
# trong server { ... }
include snippets/security-headers.conf;
location /build/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
Cách nói khi phỏng vấn: "Với Nginx có một bẫy: header khai ở khối server chỉ được kế thừa xuống location nếu location đó không tự khai thêm header nào. Chỉ cần thêm một header cache cho thư mục asset là thư mục đó mất hết security header mà không có cảnh báo. Tôi gom security header vào một file và include ở mọi nơi có khai header, luôn kèm tham số để header có cả trên trang lỗi."
Giải thích mẫu đúng:
1. Quy tắc kế thừa và always — trích docs ở mục 3.6. Nginx từ 1.29.3 có add_header_inherit merge; để
gộp thay vì ghi đè; nếu hạ tầng đã ở bản đó thì có thể thay mẫu include bằng chỉ thị này (chưa verify trên bản thật).
2. Giá trị HSTS: cheat sheet khuyên max-age=63072000 (2 năm) kèm preload; mẫu trên dùng 1 năm và chưa
preload — một bước trung gian hợp lý. Lộ trình thường gặp: bắt đầu vài phút, rồi một tuần, một tháng, rồi một/hai năm;
chỉ xin preload sau khi chắc chắn mọi subdomain đều có HTTPS, vì rút khỏi danh sách preload mất nhiều tháng.
3. Không có X-XSS-Protection — cố ý, theo cheat sheet (mục 3.6).
// ===== ĐÚNG (Laravel): app chỉ sở hữu header phụ thuộc ngữ cảnh =====
// app/Http/Middleware/ContextualSecurityHeaders.php
class ContextualSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// CSP cho trang HTML do app render (Nginx không biết trang nào cần nguồn nào)
if (str_contains((string) $response->headers->get('Content-Type'), 'text/html')) {
$response->headers->set('Content-Security-Policy',
"default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
}
// Trang có dữ liệu cá nhân: không cho cache dùng chung giữ lại
if ($request->user()) {
$response->headers->set('Cache-Control', 'no-store, private');
}
return $response;
}
}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(ContextualSecurityHeaders::class);
})
Cách nói khi phỏng vấn: "Tôi chia header theo người biết rõ nhất. Nginx, nơi kết thúc TLS, sở hữu các header chung cho mọi response như HSTS và chống đoán kiểu nội dung. App sở hữu các header phụ thuộc vào từng trang: chính sách nội dung cho trang HTML, và cấm cache với trang có dữ liệu cá nhân. Không header nào được đặt ở cả hai nơi, vì hai giá trị khác nhau sẽ làm trình duyệt áp dụng cả hai."
Giải thích mẫu đúng:
1. CSP là phần khó nhất và không có mẫu chung. Chính sách trong mẫu là điểm khởi đầu chặt; trang dùng Livewire,
Alpine hoặc script inline sẽ vỡ ngay. Cách triển khai an toàn là bật ở chế độ Content-Security-Policy-Report-Only một thời
gian, thu báo cáo vi phạm, sửa, rồi mới chuyển sang chế độ chặn. Cheat sheet CSP riêng đi sâu phần này; trong bài này CSP chỉ
là một trong các header cần có chủ sở hữu rõ ràng.
2. Cache-Control: no-store theo cheat sheet cho response nhạy cảm; private chặn cache dùng chung (CDN,
proxy) nhưng cache của trình duyệt vẫn có thể lưu — cùng nhau, chúng chặn tình huống "trang bảng điểm của sinh viên A được proxy
trả cho sinh viên B".
3. $middleware->append() thêm vào cuối stack global middleware (docs Middleware → Global Middleware), nên chạy
cho mọi route kể cả API. Với API JSON, khối CSP tự bỏ qua vì Content-Type không phải HTML.
// ===== NestJS ≥ 12.1: header dựng sẵn trong framework =====
app.useSecurityHeaders({
strictTransportSecurity: false, // HSTS do Nginx (nơi kết thúc TLS) sở hữu
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
contentSecurityPolicy: { directives: { frameAncestors: ["'none'"] } },
});
await app.listen(process.env.PORT ?? 3000); // useSecurityHeaders phải gọi TRƯỚC listen/init
// ===== NestJS bản cũ hơn: helmet, đăng ký trước mọi app.use() khác =====
import helmet from 'helmet';
app.use(helmet({ strictTransportSecurity: false }));
Cách nói khi phỏng vấn: "Từ NestJS 12.1 framework tự đặt được bộ security header với cùng giá trị mặc định như thư viện Helmet, và chạy giống nhau trên Express lẫn Fastify. Tôi tắt riêng HSTS ở tầng app vì Nginx đã sở hữu header đó. Với bản Nest cũ thì dùng Helmet, nhưng phải đăng ký nó trước mọi middleware khác, nếu không những response do middleware trước đó trả về sẽ thiếu header."
Giải thích mẫu đúng:
1. useSecurityHeaders() (docs NestJS Security → Helmet): "Starting with NestJS v12.1, Nest can set these
headers for you", cùng mặc định với Helmet 8, gỡ X-Powered-By, áp dụng cả cho response 404, response lỗi, response bị
CSRF protection từ chối. Ba quy tắc quan trọng từ docs: gọi trước app.init()/app.listen() (gọi sau hoặc gọi hai lần
sẽ ném lỗi); option sai bị phát hiện ngay lúc gọi — tức là một cấu hình header sai làm app không khởi động thay vì chạy
với header hỏng (đúng tinh thần fail-fast của mục 4.1); và middleware đăng ký bằng app.use() trước lời gọi này chạy
trước, response do chúng tự gửi sẽ không có header.
2. Directive bạn đặt sẽ thay thế directive mặc định cùng tên, các directive khác giữ nguyên — nên
frameAncestors: ["'none'"] chỉ đổi frame-ancestors từ 'self' thành 'none'. Từ khoá phải có nháy đơn
("'self'"); docs ghi giá trị không nháy bị từ chối vì trình duyệt sẽ hiểu là tên host.
3. Mặc định đáng chú ý: X-XSS-Protection: 0 (đúng cheat sheet), X-Frame-Options: SAMEORIGIN (cheat sheet
khuyên DENY nếu không cần nhúng), Referrer-Policy: no-referrer (chặt hơn cheat sheet). Mặc định của thư viện và
khuyến nghị của OWASP không trùng hoàn toàn — chọn có ý thức, ghi lại lý do.
flowchart TD
A["Header cần có"] --> B{"Phụ thuộc ngữ cảnh từng route?"}
B -- "Không (HSTS, nosniff, Referrer-Policy)" --> C["Nginx sở hữu<br/>snippet chung + always"]
B -- "Có (CSP theo trang, Cache-Control dữ liệu cá nhân)" --> D["App sở hữu<br/>middleware / useSecurityHeaders"]
C --> E{"App cũng đặt header này?"}
D --> F{"Nginx cũng đặt header này?"}
E -- "Có" --> G["Tắt ở app hoặc proxy_hide_header / fastcgi_hide_header"]
F -- "Có" --> G
E -- "Không" --> H["Một header, một giá trị"]
F -- "Không" --> H
4.5 Cookie và proxy tin cậy
// ===== SAI =====
// .env production: không đặt SESSION_SECURE_COOKIE (→ null, mục 3.5)
// bootstrap/app.php
$middleware->trustProxies(at: '*'); // app node nhận được request trực tiếp từ mạng khác ngoài LB
// ===== ĐÚNG =====
// .env production
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
// bootstrap/app.php
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: ['10.0.1.10']); // IP nội bộ của Nginx LB (minh hoạ)
$middleware->trustProxies(headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
$middleware->trustHosts(at: ['^lms\.example\.edu$'], subdomains: false);
})
Cách nói khi phỏng vấn: "Tôi đặt cờ Secure cho cookie phiên một cách tường minh, không để framework tự đoán, vì khi TLS kết thúc ở load balancer thì app chỉ biết request là HTTPS nếu nó tin load balancer. App chỉ tin đúng địa chỉ của load balancer chứ không tin tất cả, vì tin tất cả nghĩa là ai gửi thẳng tới app node cũng giả được IP và giao thức. Tôi cũng giới hạn tên miền mà app chấp nhận, để link sinh ra trong email không bị trỏ sang tên miền lạ."
Giải thích mẫu đúng:
1. Hai dòng env sửa hai vấn đề khác nhau. SESSION_SECURE_COOKIE=true làm cờ Secure không còn phụ
thuộc vào việc phát hiện HTTPS. trustProxies đúng IP làm phần còn lại của app (URL sinh ra, $request->ip() dùng cho
rate limit và audit log) đúng. Thiếu cái thứ hai thì cookie vẫn an toàn nhờ cái thứ nhất, nhưng IP trong log là IP của LB — bài
Scale Laravel với Nginx + PHP-FPM (mục 3.7) phân tích chi tiết phía IP, và
bài rate limit theo phạm vi cho thấy hệ quả với rate limit.
2. Khi nào '*' chấp nhận được. Docs Laravel đưa '*' cho trường hợp "you may not know the IP
addresses of your actual balancers" (cloud LB). Điều kiện ngầm mà docs không nói ra: app node phải không nhận được request
nào không đi qua LB — bảo đảm bằng mạng (security group, firewall, app chỉ listen trên mạng nội bộ). Nếu điều kiện đó đúng
thì '*' an toàn; nếu không, bất kỳ ai gửi thẳng tới app node cũng tự khai được X-Forwarded-For/Proto. Đây là ví
dụ của cấu hình phụ thuộc lẫn nhau (mục 2, ý 3): an toàn của một dòng code nằm ở cấu hình mạng.
3. trustHosts: docs Laravel — mặc định app trả lời mọi Host header và dùng nó để sinh URL tuyệt đối.
Lý tưởng là Nginx chỉ chuyển request đúng server_name (một server mặc định trả 444/404 cho host lạ); trustHosts là
lớp thứ hai trong app. Mặc định subdomain cũng được tin; subdomains: false tắt điều đó.
// ===== SAI (NestJS + express-session): "sửa" lỗi không đăng nhập được sau LB =====
app.use(session({ secret: 'dev-secret', cookie: { secure: false } }));
// ===== ĐÚNG =====
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const config = app.get(ConfigService);
app.set('trust proxy', '10.0.1.10'); // chỉ tin IP của Nginx LB (minh hoạ)
app.use(
session({
secret: config.getOrThrow<string>('SESSION_SECRET'),
name: '__Host-sid', // trình duyệt chỉ nhận nếu có Secure, Path=/, không Domain
resave: false,
saveUninitialized: false,
cookie: {
secure: config.getOrThrow<boolean>('COOKIE_SECURE'), // schema 4.1 ép true trên production
httpOnly: true,
sameSite: 'lax',
path: '/',
},
}),
);
Cách nói khi phỏng vấn: "Ở Express, nếu cookie đặt cờ Secure mà app không tin load balancer thì cookie không được gửi đi và người dùng không đăng nhập được. Cách sửa sai mà tôi hay gặp là tắt cờ Secure. Cách đúng là cho app tin đúng địa chỉ load balancer. Tôi còn đặt tên cookie có tiền tố đặc biệt để chính trình duyệt từ chối cookie nếu nó thiếu cờ Secure, nên không ai tắt nhầm được nữa."
Giải thích mẫu đúng:
1. Trích README express-session (mục 3.5): đứng sau proxy mà dùng secure: true thì phải đặt
trust proxy; README dùng ví dụ app.set('trust proxy', 1) (tin một hop). Mẫu trên dùng IP cụ thể — chặt hơn, đúng với
topology có một LB biết trước.
2. Tiền tố __Host- là một quy ước cookie do trình duyệt thực thi: cookie có tên bắt đầu bằng __Host- chỉ
được chấp nhận nếu có Secure, Path=/ và không có Domain. Biến một quy ước cấu hình thành một ràng buộc mà
trình duyệt kiểm — nếu sau này có người đổi secure thành false, cookie bị từ chối ngay và lỗi lộ ra ở môi trường test thay
vì im lặng trên production. (Theo đặc tả cookie / MDN, chưa chạy thử với express-session — xem Chưa verify.)
3. getOrThrow của ConfigService: thiếu biến thì ném lỗi thay vì trả undefined — cùng tinh thần
fail-closed. Secret dạng 'dev-secret' hard-code trong mẫu sai là CWE-547 điển hình.
flowchart TD
A["Request tới app node"] --> B{"Đến từ IP của LB?"}
B -- "Có" --> C["Tin X-Forwarded-Proto / For / Host"]
B -- "Không" --> D["Bỏ qua X-Forwarded-*<br/>(mạng phải chặn đường này từ trước)"]
C --> E{"Host thuộc allowlist?"}
E -- "Không" --> F["Từ chối"]
E -- "Có" --> G["Xử lý, phát cookie phiên"]
G --> H["Cookie: Secure (tường minh) + HttpOnly + SameSite=Lax"]
4.6 XML: không entity ngoài, không DOCTYPE, có giới hạn kích thước
// ===== SAI: bật cờ để "entity hiển thị đúng" =====
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_DTDLOAD);
// ===== ĐÚNG (1): AppServiceProvider::boot — chặn tải entity ngoài cho cả process =====
public function boot(): void
{
// Callback TRẢ VỀ null. Truyền null trực tiếp KHÔNG có tác dụng chặn (XXE Prevention Cheat Sheet).
libxml_set_external_entity_loader(static fn () => null);
}
// ===== ĐÚNG (2): app/Support/SafeXml.php — mọi XML từ bên ngoài đi qua đây =====
final class SafeXml
{
public static function load(string $xml, int $maxBytes = 2_000_000): \DOMDocument
{
if (strlen($xml) > $maxBytes) {
throw new \InvalidArgumentException('XML vượt giới hạn kích thước');
}
$previous = libxml_use_internal_errors(true);
try {
$dom = new \DOMDocument();
if (! $dom->loadXML($xml, LIBXML_NONET)) { // không NOENT, không DTDLOAD, không mạng
throw new \InvalidArgumentException('XML không hợp lệ');
}
if ($dom->doctype !== null) { // không cần DTD → không nhận DTD
throw new \InvalidArgumentException('Không chấp nhận DOCTYPE');
}
return $dom;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
}
Cách nói khi phỏng vấn: "Với PHP hiện đại, parser XML mặc định đã không thay thế entity, nên lỗ XXE gần như luôn là do ai đó tự bật cờ. Tôi làm ba việc: không bao giờ bật các cờ cho phép thay thế entity hay tải DTD với dữ liệu từ bên ngoài, cài một bộ nạp entity ngoài luôn trả về rỗng cho cả process, và từ chối luôn tài liệu có khai báo DOCTYPE vì nghiệp vụ không cần nó. Thêm giới hạn kích thước để chặn kiểu tài liệu nở ra khi parse."
Giải thích mẫu đúng:
1. Ba lớp, mỗi lớp chặn một điều. Không bật LIBXML_NOENT/LIBXML_DTDLOAD — tuân theo mặc định an toàn
của libxml2 ≥ 2.9 (cheat sheet). Bộ nạp entity ngoài trả null — phòng hờ cho thư viện bên thứ ba (đọc file Office, gói
SCORM) có thể tự bật cờ mà bạn không kiểm soát; vì đặt ở boot() nên áp cho mọi lần dùng libxml trong process. Từ chối
DOCTYPE — đúng khuyến nghị chung "Reject DOCTYPE declarations if the parser supports it"; kiểm sau khi parse vẫn an toàn vì
hai lớp trước đã đảm bảo parse không tải gì từ ngoài.
2. Trade-off của bộ nạp toàn process: nếu hệ thống có chỗ hợp lệ cần DTD ngoài (hiếm — ví dụ validate theo DTD cục bộ của một chuẩn cũ), chỗ đó sẽ hỏng. Khi đó cài một callback chỉ trả về file DTD cục bộ trong allowlist thay vì trả null — vẫn không bao giờ cho phép URI tuỳ ý.
3. Giới hạn kích thước chặn phần lớn kiểu cạn tài nguyên ở tầng ứng dụng; cheat sheet nhắc riêng rằng chặn XXE "does not prevent every XML denial-of-service attack" — với hệ thống nhận XML lớn thường xuyên, nên parse trong worker có giới hạn bộ nhớ và thời gian, không parse trong request web.
// ===== NestJS: ưu tiên JSON; nếu bắt buộc nhận XML =====
@Post('imports/question-bank')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 2_000_000 } }))
async importQuestions(@UploadedFile() file: Express.Multer.File) {
const text = file.buffer.toString('utf8');
if (/<!DOCTYPE/i.test(text)) {
throw new UnprocessableEntityException('Không chấp nhận DOCTYPE'); // cổng thô, không thay cấu hình parser
}
const raw = this.xml.parse(text); // thư viện ĐÃ XÁC NHẬN không tải entity ngoài (đọc docs của nó)
const data = questionBankSchema.parse(raw); // validate cấu trúc nghiệp vụ sau khi parse
return this.imports.enqueue(data); // xử lý nặng ở worker, không trong request
}
Cách nói khi phỏng vấn: "Node không có parser XML dựng sẵn, nên mức an toàn phụ thuộc thư viện mà team chọn. Tôi coi việc chọn thư viện XML là một quyết định bảo mật: phải đọc tài liệu để chắc nó không tải entity từ bên ngoài. Ở endpoint, tôi giới hạn kích thước file, từ chối tài liệu có khai báo DOCTYPE, validate cấu trúc sau khi parse, và đẩy việc xử lý nặng sang worker."
Giải thích mẫu đúng:
1. Kiểm chuỗi DOCTYPE là cổng thô, không phải lớp chính: nó không bắt được tài liệu mã hoá UTF-16 (vì đang đọc theo UTF-8). Muốn chặt hơn thì từ chối mọi file không phải UTF-8 trước bước này. Lớp chính vẫn là cấu hình của parser.
2. Không nêu tên thư viện cụ thể là cố ý: hành vi entity của các thư viện XML trong hệ sinh thái Node khác nhau và thay đổi theo phiên bản; bài này chưa kiểm docs của từng thư viện (xem Chưa verify). Quy tắc review: PR thêm dependency xử lý XML phải ghi rõ trong mô tả thư viện đó xử lý DTD/entity thế nào, kèm link docs.
flowchart TD
A["Upload XML"] --> B{"Kích thước ≤ giới hạn?"}
B -- "Không" --> R["413"]
B -- "Có" --> C{"UTF-8 và không có DOCTYPE?"}
C -- "Không" --> R2["422 + log"]
C -- "Có" --> D["Parser cấu hình an toàn<br/>(không entity ngoài, không mạng)"]
D --> E["Validate schema nghiệp vụ"]
E --> F["Hàng đợi → worker xử lý"]
4.7 Công cụ phát triển: không có trong image, hoặc đóng sau gate
// ===== SAI =====
Gate::define('viewHorizon', fn ($user = null) => true); // "cho ops xem nhanh" — ai cũng xem được
// ===== ĐÚNG (1): Telescope chỉ cho local =====
// composer require laravel/telescope --dev
// composer.json
"extra": { "laravel": { "dont-discover": ["laravel/telescope"] } }
// app/Providers/AppServiceProvider.php
public function register(): void
{
if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class);
}
}
// Dockerfile (image production)
RUN composer install --no-dev --optimize-autoloader
// ===== ĐÚNG (2): Horizon cần chạy trên production → gate theo quyền thật =====
// app/Providers/HorizonServiceProvider.php
protected function gate(): void
{
Gate::define('viewHorizon', function (User $user) {
return $user->can('ops.view-queues'); // minh hoạ: dùng cơ chế phân quyền sẵn có (bài RBAC)
});
}
Cách nói khi phỏng vấn: "Công cụ chỉ dùng khi phát triển như Telescope thì tôi cài dạng dev dependency và chỉ đăng ký khi chạy ở local, nên image production không chứa nó — không có code thì không có lỗ. Công cụ cần trên production như dashboard hàng đợi thì để lại nhưng đóng sau một quyền cụ thể của hệ thống phân quyền, không bao giờ mở cho mọi người dùng đã đăng nhập."
Giải thích mẫu đúng:
1. Ba lớp cho Telescope theo docs: cài với --dev; thêm vào dont-discover để package discovery không tự
đăng ký; đăng ký tay trong register() khi môi trường là local. Thêm lớp thứ tư ngoài docs: build image với
--no-dev, khi đó class_exists trả false và lệnh preflight ở 4.1 xác nhận điều đó.
2. Vì sao docs lại cảnh báo về APP_ENV. Docs Telescope: "You should ensure you change your APP_ENV environment
variable to production in your production environment. Otherwise, your Telescope installation will be publicly
available." — vì gate viewTelescope chỉ áp dụng ngoài môi trường local. Đây là ví dụ cụ thể nhất cho việc một biến môi
trường mang quyết định bảo mật (mục 3.3).
3. Gate dùng quyền chứ không dùng danh sách email. Docs mẫu dùng in_array($user->email, [...]) — dễ hiểu nhưng
khi người rời công ty phải sửa code. Gắn vào permission của hệ thống phân quyền (xem RBAC) thì thu hồi quyền
là thao tác dữ liệu, có audit log.
// ===== SAI (NestJS) =====
SwaggerModule.setup('api', app, documentFactory, { ui: false }); // tưởng là đã "tắt Swagger"
// ===== ĐÚNG =====
if (config.get<boolean>('ENABLE_API_DOCS')) { // opt-in; schema 4.1 cấm trên production
SwaggerModule.setup('api', app, documentFactory);
}
// Cần tài liệu API cho đối tác trên production? Xuất file JSON ở CI, phát hành qua kênh có xác thực.
Cách nói khi phỏng vấn: "Một chi tiết hay bị hiểu nhầm ở NestJS: tắt giao diện Swagger không tắt file mô tả API, hai thứ đó độc lập. Nên tôi không cấu hình Swagger theo kiểu tắt bớt, mà không gọi hàm dựng tài liệu API trên production. Nếu đối tác cần tài liệu thì xuất file ở bước build và đưa qua kênh có xác thực."
Giải thích: docs NestJS OpenAPI ghi rõ "ui and raw are independent options. Disabling the Swagger UI (ui: false) doesn't
disable the API definitions (JSON/YAML)". Mẫu sai vì vậy vẫn phục vụ toàn bộ mô tả endpoint ở /api-json. Tài liệu API
lộ ra không phải lỗ hổng tự thân, nhưng nó là bản đồ đầy đủ cho bước trinh sát — đúng vai trò "khuếch đại" như Kịch bản #2 của
OWASP. Với GraphQL, introspection và IDE trên production là câu chuyện tương tự, đã phân tích ở
GraphQL — bảo mật và giới hạn query.
flowchart TD
A["Công cụ dev / vận hành"] --> B{"Cần trên production?"}
B -- "Không (Telescope, Swagger UI, GraphiQL)" --> C["Dev dependency, build --no-dev<br/>hoặc chỉ dựng khi cờ opt-in bật"]
C --> D["Preflight xác nhận không có trong image"]
B -- "Có (Horizon, health, metrics)" --> E["Gate theo permission cụ thể"]
E --> F["Thêm lớp mạng: chỉ VPN / IP nội bộ"]
F --> G["Audit log khi truy cập"]
4.8 Storage: private mặc định, URL ký có hạn ngắn
// ===== SAI =====
Storage::disk('s3')->put($path, $content, 'public'); // file bài nộp public vĩnh viễn
return Storage::disk('s3')->url($path); // URL cố định, ai có link là tải được
// ===== ĐÚNG =====
// config/filesystems.php — disk riêng cho dữ liệu người dùng, bucket private
'submissions' => [
'driver' => 's3',
'bucket' => env('SUBMISSIONS_BUCKET'),
'visibility' => 'private',
// key, secret, region, endpoint (MinIO), use_path_style_endpoint ...
],
// Controller
public function download(Submission $submission)
{
Gate::authorize('download', $submission); // A01: quyền theo object
return redirect(Storage::disk('submissions')->temporaryUrl(
$submission->file_path, now()->addMinutes(5)
));
}
Cách nói khi phỏng vấn: "Dữ liệu của người dùng nằm ở bucket riêng, private, không có quyền truy cập ẩn danh. Muốn tải file thì app kiểm quyền với đúng bản ghi đó, rồi ký một đường link chỉ đọc một file và hết hạn sau vài phút. Asset công khai như ảnh khoá học thì để ở bucket khác. Tách bucket theo mức công khai đơn giản và khó sai hơn nhiều so với kiểm quyền từng file trong một bucket chung."
Giải thích mẫu đúng:
1. temporaryUrl (docs Laravel File Storage) tạo URL tạm cho driver local và s3, nhận thời điểm hết
hạn. 5 phút là đủ cho trình duyệt bắt đầu tải; link bị chia sẻ lại sau đó vô dụng. Với file rất lớn, thời gian tải có thể lâu
hơn thời hạn — chữ ký chỉ kiểm lúc bắt đầu request, nên không vấn đề (theo hiểu biết về S3, chưa verify với MinIO).
2. Vụ Microsoft 2023 (mục 8) là bài học về ba chiều của một link ký: phạm vi (một object hay cả account), quyền (đọc hay toàn quyền), thời hạn (phút hay 30 năm). Mẫu đúng ở trên chốt cả ba ở mức nhỏ nhất: một object, chỉ đọc, 5 phút.
// ===== NestJS (AWS SDK v3, dùng được với MinIO qua endpoint tương thích S3) =====
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async downloadUrl(submissionId: number, user: AuthUser): Promise<string> {
const sub = await this.submissions.findVisible(submissionId, user); // ownership trong query — Bài 1 mục 4.1
return getSignedUrl(
this.s3,
new GetObjectCommand({ Bucket: this.bucket, Key: sub.fileKey }),
{ expiresIn: 300 }, // mặc định 900 nếu bỏ trống
);
}
Cách nói khi phỏng vấn: "Ở NestJS cũng vậy: kiểm quyền với bản ghi trước, rồi ký đường link cho đúng một file với thời hạn năm phút. Tôi luôn đặt thời hạn tường minh thay vì để mặc định của thư viện, vì người đọc code cần thấy ngay link sống bao lâu."
flowchart TD
A["Thiết kế storage"] --> B["Bucket dữ liệu người dùng: private"]
A --> C["Bucket asset công khai: tách riêng"]
B --> D["Truy cập chỉ qua URL ký:<br/>1 object, chỉ đọc, vài phút"]
D --> E["Sau khi kiểm quyền object-level"]
B --> F["Kiểm định kỳ: không có policy ẩn danh,<br/>không có access key dài hạn trong code"]
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 chuẩn" cho A02. OWASP đưa ra danh sách prevention; CIS Benchmarks đưa ra baseline cho từng sản phẩm (Nginx, Ubuntu, Docker, MySQL); docs framework đưa ra mặc định và cảnh báo. Mỗi nguồn phủ một tầng. Bảng dưới gom lại theo câu hỏi quan trọng với tech lead: mỗi lớp không che được gì.
5.1 Bảng tổng hợp
| Lớp | Chặn được | Không chặn được | Hợp với bối cảnh nào |
|---|---|---|---|
| Mặc định an toàn trong code (fail-closed, opt-in cho tính năng dev) | Sai sót do thiếu biến, gõ nhầm tên biến | Người cố ý đặt giá trị sai; cấu hình ngoài app (Nginx, cloud) | Mọi dự án — chi phí gần bằng 0 |
| Schema env / preflight khi khởi động (4.1) | File env sai trên server, kể cả file không nằm trong repo | Web server, quyền bucket, node khác; thay đổi sau khi đã khởi động | Mọi dự án có deploy tự động; đặc biệt hợp blue-green |
| Config as code + review (IaC, Nginx trong repo, Ansible/Terraform) | Thay đổi không ai biết; giúp review và rollback | Sửa tay ngoài pipeline; lỗi mà reviewer cũng không nhận ra | Hạ tầng tự quản (VPS, on-prem) — nơi không có console cloud ghi lịch sử |
| Policy check trong CI (lint IaC, scan image, test cấu hình) | Mẫu sai đã biết (root sai, autoindex on, container chạy root) | Cấu hình chỉ tồn tại lúc chạy; logic sai mà policy không mô tả | Đội đã có CI ổn định; xem static analysis trong CI |
| Immutable image, build một lần cho mọi môi trường | Drift tầng app giữa môi trường/node | Khác biệt ở file env, ở LB, ở hạ tầng ngoài container | Đội đã container hoá (như LMS Micro-learning) |
| Dò từ bên ngoài sau deploy (header, đường dẫn cấm, phiên bản) | Những gì người ngoài thật sự thấy, kể cả do Nginx/CDN gây ra | Cấu hình bên trong (quyền DB, Redis nội bộ); node không được LB chọn lúc dò | Mọi hệ thống public; nên dò từng node chứ không chỉ qua LB |
| Phát hiện drift định kỳ | Sửa tay, bật debug "tạm", node dựng lại từ snapshot cũ | Sai ngay từ bản chuẩn (drift so với bản chuẩn sai thì vẫn "không drift") | Hệ thống nhiều node, có trực vận hành |
| Phân đoạn mạng (segmentation) | Giảm hậu quả khi một dịch vụ cấu hình sai bị lộ (Redis, DB không ra Internet) | Lỗi ở chính tầng public (debug page, header) | Mọi hệ thống nhiều thành phần — OWASP prevention có mục riêng |
| Credential ngắn hạn / danh tính nền tảng thay cho key tĩnh | Key lộ trong code, config, pipeline bị dùng lâu dài | Quyền của danh tính đó quá rộng | Cloud có IAM role; với VPS/MinIO tự quản thì ít nhất tách key theo app và xoay vòng |
| Kiểm tra định kỳ bằng tay (nếu chưa tự động) | Một phần mọi thứ ở trên | Mọi thứ thay đổi giữa hai lần kiểm | OWASP: "should be manually verified annually at a minimum" — mức sàn, không phải mục tiêu |
5.2 Đặt cấu hình bảo mật ở đâu: trong app hay ở hạ tầng
Đây là câu hỏi lặp lại ở mọi tiểu mục của mục 4 (header, TLS, chặn đường dẫn, giới hạn kích thước), và có hai trường phái hợp lệ:
1. Tập trung ở hạ tầng (Nginx/LB/CDN/ingress). Một chỗ cấu hình cho nhiều app viết bằng nhiều ngôn ngữ — LMS Uni có Moodle (PHP) và student portal (NestJS) sau cùng một Nginx LB. Phủ cả response do hạ tầng tự sinh (trang lỗi 502, file tĩnh). Đội vận hành kiểm soát được mà không cần deploy app. Nhược điểm: hạ tầng không biết ngữ cảnh của từng route, nên chính sách thường là mẫu số chung lỏng nhất; và cấu hình hạ tầng hay nằm ngoài repo của app, nên dev không thấy.
2. Đặt trong app. Gần ngữ cảnh: CSP theo trang, Cache-Control theo dữ liệu, header theo user. Đi cùng code
qua review và test; chạy giống nhau ở local và production (môi trường local thường không có Nginx giống production). Nhược điểm:
mỗi app tự làm lại; dễ trùng hoặc lệch với hạ tầng.
3. Cách kết hợp thực dụng (đã dùng ở 4.4): phân chủ sở hữu theo ai biết rõ nhất — thứ gì gắn với transport (HSTS, TLS, chặn file ẩn) thuộc hạ tầng; thứ gì gắn với nội dung thuộc app — và viết bảng phân công đó vào tài liệu kiến trúc. Điều cần tránh là "cả hai cùng đặt để chắc ăn": header trùng không làm an toàn hơn mà làm hành vi khó đoán.
5.3 Hardening theo baseline: CIS và giới hạn của nó
OWASP dẫn CIS Benchmarks và NIST SP 800-123 làm tài liệu tham khảo cho hardening. Giá trị của baseline là danh sách đã được nhiều người rà, có thể kiểm bằng công cụ. Giới hạn cần nói thẳng khi phỏng vấn:
- Baseline không biết ứng dụng của bạn. Nó nói "tắt
server_tokens", không nói "rootphải làpublic/của Laravel". - Áp dụng 100% baseline thường làm hỏng thứ gì đó; đội cần ghi lại từng ngoại lệ và lý do — và đó lại là nơi sinh ra "ngoại lệ tương thích ngược không ai gỡ" (mục 2, ý 5). Ngoại lệ phải có người chịu trách nhiệm và ngày xem lại.
- Điểm baseline cao không có nghĩa là an toàn: một bucket public chứa dữ liệu sinh viên không làm giảm điểm CIS của Ubuntu.
5.4 Nếu chỉ có một sprint: thứ tự ưu tiên
- Debug và công cụ dev trên production (4.1, 4.7) — impact lớn nhất, sửa rẻ nhất, có CVE bị khai thác thật (mục 8).
- Quyền storage (4.8) — rà mọi bucket, không còn policy ẩn danh trên bucket dữ liệu người dùng.
- Web root và file ẩn (4.3) — một lần dò từ ngoài cho danh sách đường dẫn cấm, trên từng node.
- Cookie và proxy (4.5) — đặt
Securetường minh, sửatrustProxies. - Preflight trong pipeline — biến 1–4 thành kiểm tra tự động để không tái phát.
- Security header, XML, drift detection — ở sprint sau.
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 |
|---|---|
env('...') ngoài thư mục config/ | Sau config:cache, env() ngoài config trả null — hành vi production khác local (xem Tối ưu Laravel). Biến có ảnh hưởng bảo mật thì mặc định null có an toàn không? |
.default(...) trong schema env, env('X', true) | Mặc định này fail-open hay fail-closed? |
NODE_ENV !== 'production', app()->environment('local') bật tính năng | Có nên là cờ opt-in riêng không? |
getMessage(), getTraceAsString(), exception.stack trong response | Có đi qua handler tập trung không? (mục 4.2) |
LIBXML_NOENT, LIBXML_DTDLOAD, dependency XML mới | Dữ liệu có đến từ bên ngoài không? Thư viện xử lý DTD thế nào? (4.6) |
'public' trong Storage::put, bucket mới, link ký có hạn dài | Phạm vi, quyền, thời hạn của truy cập? (4.8) |
trustProxies(at: '*'), app.set('trust proxy', true) | Mạng có bảo đảm chỉ LB tới được app không? (4.5) |
secure: false, SESSION_SECURE_COOKIE trống | Đang "sửa" lỗi proxy bằng cách hạ an toàn? (3.5) |
add_header trong một location mới | Location này có mất security header kế thừa không? (4.4) |
Package dev mới trong require thay vì require-dev | Nó có lên image production không? (4.7) |
6.2 Kiểm tra tự động trong CI
1. Test cấu hình như test code. Một test chạy với file env mẫu của production (không có secret thật) và gọi
security:preflight — đảm bảo chính lệnh preflight không bị ai sửa hỏng. Ở Nest, unit test cho envSchema: một bộ biến
production có ENABLE_API_DOCS=true phải bị từ chối.
2. Lint cấu hình hạ tầng. OWASP IaC Cheat Sheet liệt kê nhóm công cụ (IDE plugin như TFLint, Checkov; static
analysis; quét image container như Trivy, Clair). Tên công cụ thay đổi theo thời gian; điều cần nói khi phỏng vấn là loại
kiểm tra: lint file IaC, quét image (gói thừa, chạy bằng root, secret trong layer), và kiểm cấu hình Nginx bằng
nginx -t cộng một bộ test HTTP tối thiểu trên container dựng tạm.
3. Quét secret trong repo và trong lịch sử commit — phần này thuộc Bài 3; ở góc A02 chỉ cần nhắc rằng secret đã commit thì phải xoay vòng, xoá khỏi lịch sử không đủ.
6.3 SAST, DAST và scanner bên ngoài: phủ được gì
A02 là nhóm có coverage cao nhất (max 100%) chính vì DAST (quét ứng dụng đang chạy) phát hiện rất tốt: thiếu header, cookie thiếu cờ, đường dẫn mặc định, trang lỗi lộ stack trace, phiên bản trong header. HTTP Headers Cheat Sheet giới thiệu Mozilla Observatory để kiểm header. Giới hạn của DAST: chỉ thấy qua đường LB đưa nó tới (một node), không thấy cấu hình nội bộ (Redis không mật khẩu trong mạng riêng), và báo rất nhiều mục impact thấp — cần phân loại để đội không bị chìm trong cảnh báo. SAST bắt được các mẫu trong code (bật cờ XML nguy hiểm, trả stack trace) nhưng mù với file env và cấu hình hạ tầng.
6.4 Dò sau deploy, drift, log và alert
1. Smoke test bảo mật sau deploy — chạy từ bên ngoài, trên từng node (gọi thẳng IP node với header
Host đúng, từ mạng nội bộ), không chỉ qua LB. Ý tưởng giống check publicpaths của Moodle: một danh sách đường dẫn
mong đợi 404/403 (file ẩn, composer.json, thư mục vendor, đường dẫn công cụ dev) và một danh sách header
mong đợi có mặt (HSTS, nosniff) / mong đợi vắng mặt (X-Powered-By, version trong Server). Sai một mục là
deploy bị đánh dấu lỗi. Danh sách này là tài sản của team, lớn dần sau mỗi sự cố.
2. Drift detection. Mức đơn giản nhất cho hệ thống VPS: lưu checksum của các file cấu hình quan trọng (Nginx,
php.ini, pool PHP-FPM, config.php của Moodle) trong repo; một job định kỳ so checksum trên từng node và báo khi lệch.
Mức cao hơn: chạy lại công cụ quản lý cấu hình (Ansible ở chế độ kiểm tra không sửa) và báo các thay đổi nó sẽ làm. Cảnh báo
drift phải dẫn tới hành động "đưa node về bản chuẩn" hoặc "cập nhật bản chuẩn qua review" — không có lựa chọn thứ ba là để đó.
3. Log và metric gợi ý cấu hình sai (liên quan Bài 9):
- Tỉ lệ 404 tăng đột biến trên các đường dẫn quen thuộc của công cụ dev và file cấu hình — có người đang quét; xem ngay các đường dẫn đó trả gì.
- Bất kỳ response 200 nào cho đường dẫn nằm trong danh sách cấm — alert mức cao, vì nghĩa là lớp chặn đã hỏng.
- Log ứng dụng ở mức
debugxuất hiện trên production — dấu hiệu ai đó vừa đổi cấu hình log/debug. - Truy cập dashboard vận hành (Horizon, Telescope nếu có) — log ai, lúc nào.
- Với storage: log truy cập ẩn danh vào bucket dữ liệu người dùng phải bằng 0.
4. Gánh nặng vận hành. Ai sở hữu danh sách đường dẫn cấm, bảng chủ sở hữu header, bản chuẩn cấu hình? Nếu câu trả lời là "mọi người", thì thực tế là không ai. Với đội nhỏ, giao cho tech lead hoặc người trực vận hành, xem lại theo quý và sau mỗi lần nâng cấp lớn (đúng dấu hiệu số 5 của OWASP: nâng cấp xong nhưng tính năng bảo mật mới chưa bật).
7. Áp dụng vào hệ thống trong CV
Mục này dựa trên cv-v3.md. Chỗ có dấu * là giả định không có trong CV — cần anh xác nhận trước khi dùng làm câu trả
lời phỏng vấn.
7.1 LMS Uni — Moodle sau Nginx LB, 3 app node, NFS moodledata
1. moodledata không được nằm dưới web root. File mẫu config-dist.php của Moodle (nhánh
MOODLE_405_STABLE) ghi về thư mục dữ liệu: "it should not be accessible directly via the web". Với kiến trúc trong CV —
moodledata trên NFS, mount vào 3 node — câu hỏi interviewer có thể hỏi: đường mount nằm ở đâu so với thư mục code mà Nginx phục
vụ? Moodle có check unsecuredataroot trong báo cáo Security overview để phát hiện đúng lỗi này. Câu trả lời tốt nêu được cả
hai: đường dẫn nằm ngoài web root, và có check tự động của chính Moodle xác nhận.*
2. config.php nhất quán trên 3 node. Moodle đọc cấu hình từ config.php trên từng node. Một lần sửa tay
trên một node (ví dụ bật $CFG->debugdisplay để điều tra lỗi — config-dist.php ghi rõ dòng này "NOT FOR PRODUCTION
SERVERS!") tạo đúng kiểu drift ở Hình 1, và vì LB chia tải nên lỗi chỉ hiện với khoảng 1/3 request — khó phát hiện bằng một
lần kiểm tay. Cách trả lời: config.php được quản lý từ một nguồn (repo hoặc công cụ cấu hình) và triển khai giống nhau tới
mọi node; có check so sánh checksum giữa các node.* Check displayerrors của Moodle cũng báo khi lỗi PHP được hiển thị ra
trang.
3. Moodle sau LB kết thúc TLS: sslproxy và reverseproxy. config-dist.php có hai setting:
$CFG->sslproxy = true ("Enable when using external SSL appliance") và $CFG->reverseproxy. Đây là phiên bản
Moodle của bài toán ở mục 3.5: nếu Moodle không biết request gốc là HTTPS thì sinh URL và cookie sai. Moodle có check
cookiesecure (source lib/classes/check/http/cookiesecure.php): site HTTPS mà cookie không secure là
ERROR. Phát biểu nên dùng: "chúng tôi chạy báo cáo Security overview của Moodle sau mỗi lần nâng cấp và sau mỗi thay đổi
hạ tầng".*
4. Đường dẫn không được phục vụ. Check publicpaths (mục 3.4) mong đợi 404 cho vendor/,
node_modules/, file ẩn, composer.json, upgrade.txt, thư mục test... — Moodle tự nhắc người vận hành rằng mã
nguồn Moodle chứa nhiều file không được phép ra web. Nginx LB phải có rule tương ứng; nếu Nginx chỉ fastcgi_pass tới các
container PHP-FPM thì cần kiểm cả việc file tĩnh được phục vụ từ đâu.*
5. Student portal NestJS dùng chung database với Moodle (CV). Hai rủi ro cấu hình: user DB của portal có quyền rộng hơn mức cần (ghi được vào bảng của Moodle trong khi chỉ cần đọc)* — tách user DB theo ứng dụng, quyền tối thiểu; và cấu hình CORS/cookie giữa portal và Moodle khi hai bên ở hai subdomain — đã phân tích ở Bài 1 mục 7.1.
6. Redis tách instance cho session, cache, lock; MySQL replica. Lỗi cấu hình kinh điển là dịch vụ dữ liệu lắng nghe trên interface public hoặc không có xác thực. Trong CV chúng nằm trong hạ tầng riêng; câu trả lời nên nêu: chỉ lắng nghe trên mạng nội bộ, có xác thực, firewall chỉ cho app node tới, và replica có user riêng chỉ đọc.* Đây là phần OWASP gọi là "segmented application architecture".
7.2 LMS Micro-learning — Laravel + NestJS, Docker, GitLab CI, MinIO, blue-green
1. "Build Docker image một lần dùng cho 3 môi trường" (CV) là lợi thế lớn cho A02: loại bỏ drift tầng app giữa staging và production — đúng khuyến nghị "configured identically, with different credentials" của OWASP. Điểm interviewer có thể đào: image dùng chung thì khác biệt giữa môi trường nằm hết ở biến môi trường — vậy cái gì kiểm biến môi trường? Câu trả lời là lớp 3 của Hình 1: preflight/schema env chạy trên môi trường green trước khi đổi upstream Nginx (mục 4.1). Nếu hiện tại chưa có bước này thì nên nói thật là "đây là điều tôi sẽ thêm".*
2. Image production không có package dev. Pipeline có Pint, Larastan, test (CV) — các bước đó cần dev dependency. Phải
tách stage: stage test cài đủ, stage build image production cài --no-dev. Nếu dùng chung một layer thì Telescope/debugbar
(nếu có) lên production cùng image.*
3. MinIO cho file người học. Bucket private cho bài nộp/tài liệu có bản quyền, bucket riêng cho asset công khai, tải về qua URL ký có hạn ngắn (4.8). Với 4 trường dùng chung một codebase, thêm một chiều: object key nên có tiền tố theo trường, và quyền tạo URL ký phải kiểm tenant — phần đó là A01 (Bài 1 mục 5.3).*
4. Laravel + NestJS + Next.js sau cùng Nginx. Bảng chủ sở hữu header (mục 5.2) đặc biệt cần ở đây: Next.js có cơ chế
header riêng, Nest có useSecurityHeaders, Laravel có middleware, Nginx có add_header — bốn nơi có thể đặt cùng một
header. Một trang trong tài liệu kiến trúc ghi "header nào do ai đặt" giải quyết được.*
5. Cookie phiên khi đổi upstream blue-green. Blue và green phải dùng cùng APP_KEY (Laravel mã hoá cookie bằng
khoá này) và cùng cấu hình cookie; nếu không, mỗi lần đổi upstream mọi người dùng bị đăng xuất — và cách "sửa" vội thường là nới
cấu hình. Đây là điểm nối giữa A02 và quy trình deploy; xem thêm
cache khi rollback blue-green.*
7.3 Trả lời mẫu
Câu hỏi: "Anh đảm bảo cấu hình production an toàn thế nào khi có 3 node?"
"Với LMS Uni, rủi ro lớn nhất không phải một setting cụ thể mà là ba node lệch nhau, vì load balancer chia tải nên lỗi trên một node chỉ hiện với một phần ba request. Tôi xử lý theo ba lớp. Cấu hình — Nginx, PHP-FPM, file config của Moodle — có một nguồn duy nhất và được triển khai giống nhau tới cả ba node. Sau mỗi thay đổi, tôi chạy báo cáo Security overview của Moodle, nó tự kiểm thư mục dữ liệu có lộ ra web không, lỗi có bị hiển thị không, cookie có cờ secure không. Và có kiểm tra định kỳ so sánh cấu hình giữa các node để bắt những lần sửa tay lúc xử lý sự cố. Với LMS Micro-learning thì dễ hơn vì image build một lần cho mọi môi trường, nên phần cần kiểm chỉ còn biến môi trường — và việc đó tôi đặt thành bước bắt buộc trước khi chuyển traffic trong blue-green."
8. Anti-patterns & bài học thực tế
8.1 Sự cố công khai
1. Toyota Nhật Bản, 2023 — cloud cấu hình sai trong gần 10 năm. Theo thông báo của Toyota (được TechCrunch và nhiều báo dẫn lại), thông tin của khoảng 2,15 triệu khách hàng tại Nhật — email đăng ký, số khung, vị trí xe theo thời gian, video từ camera hành trình — bị để truy cập được từ Internet từ 06/11/2013 tới 17/04/2023 do "misconfiguration of the Cloud environment". Toyota nêu nguyên nhân chính là quy định xử lý dữ liệu không được giải thích và thực thi đầy đủ, và cho biết sẽ đưa vào hệ thống giám sát cấu hình cloud. Bài học: thời gian lộ (gần 10 năm) mới là con số đáng sợ — nó cho thấy không có lớp 4–5 của Hình 1 (dò từ ngoài, phát hiện drift).
2. Microsoft AI research, 2023 — link chia sẻ quá rộng, quá lâu. Theo Wiz Research, một repo GitHub công khai hướng dẫn tải model AI qua một URL Azure Storage có SAS token (link ký). Token được cấu hình cho toàn bộ storage account thay vì một thư mục, quyền "full control" thay vì chỉ đọc, và hạn được gia hạn tới 2051 — làm lộ thêm 38TB dữ liệu nội bộ, gồm bản sao lưu máy trạm của nhân viên có secret, khoá riêng và hơn 30.000 tin nhắn Teams nội bộ. Wiz cũng chỉ ra quyền ghi mở ra rủi ro chuỗi cung ứng (thay file model). Microsoft vô hiệu token hai ngày sau khi nhận báo cáo. Bài học trực tiếp cho mục 4.8: ba chiều phạm vi/quyền/thời hạn của mọi link ký phải ở mức tối thiểu, và token kiểu này khó thu hồi nếu không xoay khoá ký.
3. CVE-2021-3129 — Laravel debug mode + Ignition. NVD: Ignition trước 2.5.2 cho phép thực thi mã từ xa không cần xác thực, khai thác được trên site bật debug mode với Laravel trước 8.4.2; CISA đưa vào danh mục Known Exploited Vulnerabilities. Bài học: lỗ hổng nằm ở công cụ debug, nhưng điều kiện để bị khai thác là một lỗi cấu hình. Đội nào tắt debug trên production thì không bị ảnh hưởng ngay cả trước khi vá — minh hoạ rõ nhất cho giá trị của mặc định an toàn.
4. Capital One, 2019 — đã phân tích ở Bài 1 mục 8 dưới góc SSRF; ở góc A02, vụ này thường được nhắc với cấu hình sai của WAF và quyền IAM quá rộng của role gắn với máy chủ. Không lặp lại chi tiết ở đây.
8.2 Anti-pattern thường gặp khi review
- "Tạm bật debug để điều tra, xong tắt." Không có cơ chế nào bảo đảm bước "xong tắt". Thay bằng request id + log đầy đủ (4.2) để không bao giờ cần bật.
- "Sửa" lỗi cookie sau LB bằng
secure: falsethay vì cấu hình trust proxy (3.5, 4.5). - Tắt UI Swagger và tin rằng tài liệu API đã ẩn (4.7).
- Một
add_headertronglocationxoá mất security header mà không ai biết (4.4). - Checklist header copy từ bài viết cũ: còn
X-XSS-Protection: 1; mode=block,Expect-CT,Public-Key-Pins— cheat sheet hiện đánh dấu các header này là không dùng nữa. trustProxies(at: '*')trên node nhận được traffic trực tiếp (4.5).- Dò bảo mật chỉ qua LB, trong khi lỗi nằm trên một node (6.4).
- Baseline 100% rồi rải ngoại lệ không ghi lý do, không hạn xem lại (5.3).
- Dùng tên môi trường làm công tắc bảo mật:
if (env !== 'production') openEverything()— fail-open khi tên sai (3.3). - Bucket chung cho asset công khai và dữ liệu người dùng, phân biệt bằng visibility từng object (3.8).
9. Câu hỏi phụ interviewer hay hỏi tiếp
| Câu hỏi | Trả lời ngắn |
|---|---|
| Vì sao A02 nhảy từ hạng 5 lên hạng 2? | OWASP: phần mềm ngày càng điều khiển bằng cấu hình; 100% ứng dụng được test có ít nhất một lỗi nhóm này, 719k occurrence. Một phần do nhóm này dễ phát hiện tự động (coverage 100%), nên impact trung bình chỉ 3,97. |
| Stack trace lộ ra là A02 hay A10? | Theo root cause: do debug bật → A02; do code tự bắt lỗi rồi in message → A10. Trong review thì quan trọng hơn là sửa ở handler tập trung. |
| Đặt security header ở Nginx hay ở app? | Phân chủ sở hữu: header gắn với transport (HSTS) ở nơi kết thúc TLS, header gắn với nội dung (CSP, Cache-Control) ở app. Mỗi header một nơi, ghi vào tài liệu kiến trúc. |
| Làm sao chắc file .env production đúng khi nó không nằm trong repo? | Kiểm khi khởi động: schema env (Nest) hoặc lệnh preflight (Laravel) chạy trên môi trường mới trước khi nhận traffic; sai là không chuyển traffic. |
| XXE còn là vấn đề với PHP hiện đại không? | Ít hơn: libxml2 ≥ 2.9 mặc định không thay entity. Lỗi xảy ra khi ai đó bật LIBXML_NOENT/DTDLOAD hoặc thư viện bên thứ ba bật. Phòng: không bật cờ, loader entity ngoài trả null, từ chối DOCTYPE, giới hạn kích thước. |
| HSTS có rủi ro gì? | Đặt dài mà chứng chỉ lỗi thì người dùng hợp lệ không vào được tới khi hết hạn; preload rút ra rất lâu. Tăng max-age dần, chỉ preload khi mọi subdomain đã HTTPS. |
| Phát hiện drift trên hệ thống VPS không có Kubernetes thế nào? | Cấu hình nằm trong repo, triển khai bằng công cụ cấu hình; job định kỳ so checksum file cấu hình giữa các node hoặc chạy công cụ cấu hình ở chế độ kiểm tra; cảnh báo dẫn tới đưa node về bản chuẩn. |
Gỡ header Server/X-Powered-By có đáng không? | Đáng vì rẻ và giảm nhiễu từ scanner theo version, nhưng không phải lớp phòng thủ — cheat sheet tự nhắc kẻ tấn công có cách fingerprint khác. Ưu tiên vá phiên bản (A03). |
| Ưu tiên tương thích ngược là gì trong A02? | Giữ cấu hình kém an toàn vì client cũ (TLS cũ, SameSite=None toàn cục, CORS rộng). Mỗi ngoại lệ cần chủ sở hữu và ngày xem lại. |
| Công cụ dev cần trên production (Horizon) thì sao? | Giữ nhưng đóng sau permission cụ thể, thêm lớp mạng (VPN/IP nội bộ), log truy cập; công cụ không cần thì không có trong image. |
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:
- Cookie phiên Laravel mất cờ
SecurekhiSESSION_SECURE_COOKIEtrống và thiếutrustProxiessau LB (3.5): suy ra từ sourceStartSession.php(Laravel 13.x),Cookie.phpvàResponse.php(symfony/http-foundation 7.3); chưa gửi request thật qua một LB kết thúc TLS để xem headerSet-Cookie. Chưa kiểm Laravel 13 đang dùng symfony/http-foundation bản nào. - Message của
ModelNotFoundExceptionđi ra response JSON khi debug tắt (3.2): đọcHandler.phpnhánh 13.x (prepareException+convertExceptionToArray); chưa chạy. app.useSecurityHeaders()của NestJS ≥ 12.1: theo docs NestJS hiện hành (Security → Helmet); chưa cài, chưa xem header thật. Bản npm mới nhất lúc viết là 12.2.0.add_header_inherit(Nginx 1.29.3): theo docs nginx.org; chưa chạy trên bản thật.- Tiền tố cookie
__Host-với express-session: theo đặc tả cookie / MDN, chưa chạy; chưa kiểm express-session có tự từ chối tên này khisecurefalse hay chỉ trình duyệt từ chối. - Thư viện XML trong hệ sinh thái Node: chưa đọc docs của thư viện cụ thể nào về DTD/entity; mẫu 4.6 cố ý không nêu tên.
- Mẫu PHP XML ở 4.6 (loader toàn process, kiểm
$dom->doctypesau khi parse vớiLIBXML_NONET): theo XXE Prevention Cheat Sheet và hiểu biết về API DOM của PHP; chưa chạy với tài liệu có DOCTYPE để xác nhận thứ tự lỗi. - Chi tiết Laravel không tải lại trong lần viết này: lệnh trong
app/Console/Commandstự đăng ký (Laravel 11+),$middleware->append(),env()chuyển chuỗitruethành boolean,Storage::put(..., 'public')— theo hiểu biết về docs Laravel, chưa đối chiếu lại docs 13.x. - URL ký với file lớn (chữ ký chỉ kiểm lúc bắt đầu tải): theo hiểu biết về S3, chưa kiểm với MinIO.
- Moodle: tên check (
publicpaths,unsecuredataroot,displayerrors,cookiesecure) và nội dungconfig-dist.phpđọc từ nhánhMOODLE_405_STABLE; bản Moodle thật của LMS Uni chưa biết*. - 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: Toyota theo TechCrunch và báo chí dẫn thông báo của Toyota (chưa đọc bản gốc tiếng Nhật); Microsoft theo blog Wiz Research (bài MSRC không tải được trong lần viết này); CVE-2021-3129 theo NVD.
Nguồn
- OWASP Top 10:2025 — A02 Security Misconfiguration: top10.owasp.org (bảng điểm, mô tả, prevention, 4 kịch bản, 16 CWE)
- OWASP Top 10:2021 — A05 Security Misconfiguration (để so sánh): top10.owasp.org
- OWASP HTTP Security Response Headers Cheat Sheet: cheatsheetseries.owasp.org
- OWASP XML External Entity Prevention Cheat Sheet: cheatsheetseries.owasp.org
- OWASP Error Handling Cheat Sheet: cheatsheetseries.owasp.org
- OWASP Infrastructure as Code Security Cheat Sheet: cheatsheetseries.owasp.org
- Laravel 13.x docs: Configuration (Debug Mode, env encryption), Deployment (Nginx mẫu, Debug Mode,
optimize), Errors (context,render), Requests (Trusted Proxies, Trusted Hosts), Session, Telescope, Horizon, File Storage (temporaryUrl, visibility) - Laravel framework source nhánh 13.x:
src/Illuminate/Session/Middleware/StartSession.php,src/Illuminate/Foundation/Exceptions/Handler.php; skeletonconfig/app.php,config/session.php,.env.example— github.com/laravel/framework, github.com/laravel/laravel - symfony/http-foundation 7.3:
Response.php(prepare()),Cookie.php(isSecure()) — github.com/symfony/http-foundation - NestJS docs: Security → Helmet (
useSecurityHeaders), CORS, Exception filters, OpenAPI (ui/raw), Configuration (schema validation) - NestJS source:
packages/core/exceptions/base-exception-filter.ts,packages/core/constants.ts— github.com/nestjs/nest - expressjs/session README (
cookie.secure, trust proxy): github.com/expressjs/session - AWS SDK for JavaScript v3 —
@aws-sdk/s3-request-presignerREADME: github.com/aws/aws-sdk-js-v3 - nginx docs: ngx_http_headers_module (
add_header,always, kế thừa,add_header_inherit), ngx_http_core_module → server_tokens - Moodle source
MOODLE_405_STABLE:config-dist.php,lib/classes/check/environment/publicpaths.php,unsecuredataroot.php,displayerrors.php,lib/classes/check/http/cookiesecure.php— github.com/moodle/moodle - NVD — CVE-2021-3129: nvd.nist.gov
- Wiz Research, "38TB of data accidentally exposed by Microsoft AI researchers" (09/2023): wiz.io
- TechCrunch, "Toyota Japan exposed millions of vehicles' location data for a decade" (05/2023): techcrunch.com; thông báo gốc của Toyota: global.toyota
- Trong repo: Bài 0 — tổng quan, Bài 1 — A01, RBAC, Scale Laravel với Nginx + PHP-FPM, Tối ưu Laravel, Rate limit theo phạm vi, GraphQL — bảo mật và giới hạn query, Chiến lược deployment, Cache khi rollback blue-green,
cv-v3.md
Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).
Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 1 — A01 Broken Access Control · Bài 2 — A02 Security Misconfiguration (bài này) · Tiếp: Bài 3 — A03 Software Supply Chain Failures →