Bài 8 — A08:2025 Software or Data Integrity Failures: deserialization, mass assignment, dữ liệu ký qua ranh giới tin cậy, payload cache/queue, artifact và bản cập nhật

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 7 — A07 Authentication Failures · Bài 8 — A08 Software or Data Integrity Failures (bài này) · Tiếp: Bài 9 — A09 Security Logging and Alerting Failures →

Integrity (toàn vẹn — dữ liệu hoặc code đúng là thứ bên tạo ra đã tạo, không bị ai sửa dọc đường) là chữ mà backend developer hay coi là "việc của TLS". TLS đúng là giữ toàn vẹn trên đường truyền. Nhưng A08 nói về một chuyện khác: dữ liệu và code đi một vòng qua nơi mình không kiểm soát rồi quay về, và ứng dụng tin nó như thể nó chưa từng rời khỏi server. Cookie do server đặt rồi trình duyệt gửi lại; state của component Livewire nằm trong HTML rồi quay về trong request kế tiếp; payload job nằm trong Redis vài phút rồi worker đọc ra; script uploader được CI tải từ internet mỗi lần build; file ZIP plugin Moodle tải từ thư mục plugin; bản cập nhật firmware. Ở mỗi chỗ đó, câu hỏi chỉ có một: "lúc nhận lại, mình đã kiểm chứng được nó chưa bị sửa và đúng là do bên mình tin tạo ra chưa — hay mình chỉ giả định thế?" Câu interviewer muốn nghe ở mức Senior/Tech Lead: em vẽ được các ranh giới tin cậy (trust boundary) của hệ thống, chỉ ra chỗ nào dữ liệu ra ngoài rồi quay về, ở mỗi chỗ em dùng hash, MAC hay chữ ký số và vì sao, và em không bao giờ để một định dạng có khả năng tạo object (native serialization) nhận dữ liệu từ phía bên kia ranh giới.

Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP Top 10:2025 và 2021, OWASP Cheat Sheet Series, PHP manual, MDN, docs Laravel 13 / NestJS / Livewire / BullMQ tra qua Context7, source laravel/framework 13.x, skeleton laravel/laravel 13.x, Livewire và Moodle MOODLE_405_STABLE đọc trực tiếp) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở mục 10. Bài tập trung vào phòng thủ: cơ chế được giải thích ở mức "giả định nào trong code sai và hệ quả là gì", không có chuỗi khai thác.

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

1.1 Định nghĩa: coi dữ liệu hoặc code chưa được kiểm chứng là đáng tin

1. OWASP mô tả A08 là lỗi của "code and infrastructure that does not protect against invalid or untrusted code or data being treated as trusted and valid" — code và hạ tầng không ngăn được việc code hoặc dữ liệu không hợp lệ, không đáng tin bị đối xử như đáng tin và hợp lệ. Phần Background nói rõ trọng tâm: "the failure to maintain trust boundaries and verify the integrity of software, code, and data artifacts at a lower level than Software Supply Chain Failures" — không giữ được ranh giới tin cậy và không kiểm toàn vẹn của phần mềm, code, dữ liệu ở mức thấp hơn A03. Hiểu đơn giản: A03 nhìn cả chuỗi cung ứng (tin ai, phiên bản nào, pipeline nào); A08 nhìn vào điểm nhận — đúng dòng code nhận một thứ từ bên ngoài và dùng nó mà không kiểm.

2. Các dấu hiệu OWASP nêu trong phần Description, gom lại theo "thứ được nhận vào":

Thứ được nhận vàoDấu hiệu OWASP nêu (diễn ý)Giả định sai
Plugin, thư viện, moduleLấy từ nguồn, repository, CDN không đáng tin"Tải được về là dùng được"
Code trong pipeline CI/CDPipeline kéo code/artifact từ nơi không tin cậy hoặc không kiểm chữ ký trước khi dùng; pipeline không "consume and provide" kiểm tra toàn vẹn"Lần trước file này vẫn tốt"
Bản cập nhật tự độngTải về và áp dụng mà không kiểm toàn vẹn đủ mạnh — kẻ tấn công có thể phát bản cập nhật của chính họ tới mọi bản cài"Server cập nhật là của vendor"
Object / dữ liệu đã serializeĐược mã hoá hoặc serialize thành cấu trúc mà kẻ tấn công nhìn thấy và sửa được → insecure deserialization"Chuỗi này do chính server tạo ra"

3. Cách đọc bảng ở mức tech lead: bốn dòng trông rất khác nhau (một bên là DevOps, một bên là code xử lý request) nhưng là cùng một lỗi tư duy — dùng nguồn gốc giả định thay cho nguồn gốc được chứng minh. Vì vậy phòng thủ cũng cùng một khuôn: (a) xác định chỗ dữ liệu/code vượt ranh giới tin cậy; (b) ở đó gắn một bằng chứng toàn vẹn mà bên kia không giả được (MAC, chữ ký số, digest lấy từ kênh khác); (c) kiểm bằng chứng trước khi làm bất cứ việc gì với nội dung; (d) giảm sức mạnh của định dạng (dùng định dạng chỉ chứa dữ liệu như JSON, không chứa "lệnh tạo object").

1.2 Thay đổi so với 2021

1. Tên: "Software and Data Integrity Failures" → "Software or Data Integrity Failures". OWASP gọi đây là "a slight, clarifying name change" — chỉ một trong hai (phần mềm hoặc dữ liệu) mất toàn vẹn là đã thuộc mục này.

2. Hạng: giữ hạng 8 (A08:2021 là mục mới của năm 2021, gộp A8:2017 Insecure Deserialization vào). Bài 0 đã ghi một chi tiết đáng chú ý: theo điểm tổng hợp, A08 (385,35) thấp hơn nhóm X01 Lack of Application Resilience nhưng vẫn giữ chỗ trong Top 10 — xem Bài 0.

3. Số CWE: 10 → 14. Bốn CWE mới: CWE-427 Uncontrolled Search Path Element, CWE-506 Embedded Malicious Code, CWE-509 Replicating Malicious Code (Virus or Worm), CWE-926 Improper Export of Android Application Components. Mười CWE cũ giữ nguyên.

4. Mô tả và phòng thủ đổi những chỗ nhỏ nhưng có ý nghĩa:

  1. Thêm cụm "trust boundaries" và "at a lower level than Software Supply Chain Failures" — tách vai trò rõ với A03 mới mở rộng.
  2. Câu phòng thủ về dữ liệu serialize đổi chiều: 2021 viết "not sent to untrusted clients without some form of integrity check" (đừng gửi ra); 2025 viết "not received from untrusted clients and subsequently used without some form of integrity check or digital signature to detect tampering or replay" (đừng nhận về rồi dùng). Điểm kiểm đặt ở phía nhận — đúng chỗ lỗi thật xảy ra — và có thêm chữ replay (phát lại): dữ liệu đúng chữ ký nhưng cũ cũng phải bị từ chối.
  3. Bỏ gạch đầu dòng "dùng công cụ như OWASP Dependency Check/CycloneDX để kiểm component có lỗ hổng đã biết" — việc này chuyển hẳn sang A03.
  4. Kịch bản: 2021 có SolarWinds; 2025 bỏ SolarWinds (A03 đã dùng làm ví dụ) và thêm hai kịch bản mới: "Inclusion of Web Functionality from an Untrusted Source" (subdomain trỏ sang nhà cung cấp hỗ trợ, cookie của domain chính bị gửi sang đó) và "Use of Package from an Untrusted Source" (developer tải package từ một website thay vì package manager, package không ký, chứa mã độc). Kịch bản deserialization Java giữ nguyên.

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

Chỉ sốA08:2021A08:2025Đọc thế nào
CWE ánh xạ1014Thêm search path, mã độc nhúng/tự nhân bản, Android export
Max incidence rate16,67%8,98%Ứng dụng tệ nhất về mục này giảm từ ~1/6 xuống ~1/11 điểm kiểm tra dính lỗi
Avg incidence rate2,05%2,75%Trung bình nhích lên
Max coverage75,04%78,52%Gần như không đổi
Avg coverage45,35%45,49%Gần như không đổi
Avg weighted exploit6,947,11Dễ khai thác hơn một chút
Avg weighted impact7,944,79Giảm mạnh — 2021 mục này có điểm tác động thuộc nhóm cao nhất; 2025 các CWE phổ biến (cookie, mass assignment) kéo trung bình xuống
Total occurrences47.972501.327Tăng ~10,45 lần (một phần do tập dữ liệu 2025 lớn hơn)
Total CVEs1.1523.331Tăng ~2,89 lần
Cách nói khi phỏng vấn: "A08 năm 2025 vẫn hạng 8, chỉ đổi chữ 'and' thành 'or' để nói rõ là mất toàn vẹn của phần mềm hoặc của dữ liệu đều tính. Số lần xuất hiện tăng khoảng mười lần nhưng điểm tác động trung bình lại giảm từ gần 8 xuống dưới 5 — em đọc là mục này giờ chủ yếu gặp ở dạng phổ biến như tin cookie hay mass assignment, còn dạng nặng như deserialization dẫn tới chạy code thì ít hơn nhưng mỗi lần gặp vẫn rất nặng. Thay đổi em thấy quan trọng nhất là câu phòng thủ: OWASP chuyển điểm kiểm sang phía nhận, và thêm yêu cầu chống phát lại."

1.4 Gom 14 CWE theo cơ chế

NhómCWEGiả định sai trong codeMục trong bài
Không kiểm tính xác thực / không có cơ chế kiểm toàn vẹn345, 353"Dữ liệu có đúng cấu trúc là dữ liệu thật"; "giao thức này không cần checksum/chữ ký"3.5, 4.4, 4.5
Tin cookie cho quyết định bảo mật565, 784"Cookie do server đặt nên client không sửa"; "cookie role là đủ để biết quyền"3.4, 4.6
Nhận chức năng/code từ nơi không kiểm soát829, 830, 494"Script từ CDN/URL này luôn là bản đó"; "tải về rồi chạy luôn cũng được"3.7, 4.7
Đường tìm kiếm (search path) không kiểm soát426, 427"Lệnh/thư viện sẽ được tìm thấy ở đúng chỗ mình nghĩ"3.8
Deserialization dữ liệu không tin cậy502"Chuỗi serialize này do chính mình tạo ra"3.1, 3.2, 3.6, 4.1, 4.2
Gán thuộc tính object theo dữ liệu động (mass assignment)915"Form chỉ có 3 ô nên request chỉ có 3 field"3.3, 4.3
Mã độc nhúng / tự nhân bản506, 509"Code đã vào repo/artifact là code team viết"3.8, link A03
Component Android export sai926"Activity/Service nội bộ thì app khác không gọi được"Ngoài phạm vi (xem dưới)

CWE-926 thuộc ứng dụng Android (khai báo exported trong manifest), không liên quan stack backend Laravel/NestJS mà series này nhắm tới, nên bài chỉ ghi tên và không đi sâu. Ý tưởng chung vẫn giống: một thành phần "nội bộ" bị lộ ra để bên ngoài gọi được mà không kiểm ai gọi.

1.5 Ranh giới với A03, A04, A01 và A05

flowchart TD
  Q["Sự cố: code hoặc dữ liệu bị sửa / giả mạo"] --> C1{"Ở điểm nhận có kiểm<br/>toàn vẹn không?"}
  C1 -- "Không kiểm gì cả<br/>(unserialize, tin cookie,<br/>curl rồi chạy, bind cả request)" --> A08["A08 Software or Data<br/>Integrity Failures"]
  C1 -- "Có kiểm, nhưng cơ chế yếu<br/>(hash nhanh, so sánh ==,<br/>key yếu, alg do token tự khai)" --> A04["A04 Cryptographic Failures"]
  C1 -- "Có kiểm đúng, nhưng bên<br/>mình tin đã bị chiếm<br/>(maintainer, registry, CI vendor)" --> A03["A03 Software Supply<br/>Chain Failures"]
  A08 --> R{"Hệ quả cuối cùng là gì?"}
  R -- "Leo quyền (role, owner_id)" --> A01["Thường cũng chạm A01<br/>(phân loại theo nguyên nhân: A08)"]
  R -- "Chạy code trên server" --> A05["Giống injection về hệ quả<br/>nhưng CWE-502 thuộc A08"]

1. A08 và A03. Bài A03 đã chốt cách phân biệt: A03 hỏi ta tin ai, A08 hỏi ta có kiểm chứng niềm tin đó không. Ví dụ Codecov (mục 8.1): script uploader trên server của vendor bị sửa — đó là sự cố chuỗi cung ứng của vendor (A03). Nhưng phía khách hàng, pipeline tải script và chạy ngay không so checksum — đó là A08 ở phía khách hàng, và chính khách hàng có so checksum đã phát hiện ra vụ việc.

2. A08 và A04. A04 là "có dùng mật mã nhưng dùng sai" — ví dụ có HMAC cho webhook nhưng so chữ ký bằng == (không constant-time), hoặc key HMAC 8 ký tự. A08 là "không có bước kiểm nào". Bài A04 mục 4.4 đã viết kỹ phần so sánh constant-time cho chữ ký webhook và signed URL — bài này chỉ dùng lại kết quả và tập trung vào chỗ nào cần ký, ký cái gì, kiểm theo thứ tự nào.

3. A08 và A01. Mass assignment đặt is_admin = true có hệ quả là leo quyền (A01), nhưng nguyên nhân gốc là code nhận một object do client quyết định cấu trúc (CWE-915 nằm trong A08). Trong phỏng vấn, nói được cả hai: phân loại theo nguyên nhân, phòng thủ ở cả hai tầng (allowlist field ở A08, kiểm quyền ở A01).

4. A08 và A05. Deserialization không an toàn có thể dẫn tới chạy code giống injection, và bài A05 đã ghi rõ unserialize() dữ liệu không tin cậy thuộc A08. Khác biệt cơ chế: injection là dữ liệu bị thông dịch thành lệnh trong một ngôn ngữ khác (SQL, shell); deserialization là dữ liệu điều khiển việc tạo object và gọi các method có sẵn trong chính ứng dụng.

Cách nói khi phỏng vấn: "Em phân ô theo câu hỏi ở điểm nhận. Không kiểm gì cả là A08. Có kiểm nhưng dùng mật mã sai, ví dụ so chữ ký bằng phép so sánh thường hay key quá yếu, là A04. Kiểm đúng mà bên mình tin bị chiếm, như vendor bị xâm nhập, là A03. Còn hệ quả cuối có thể là leo quyền hay chạy code, nên một sự cố A08 thường chạm cả A01 hoặc giống A05 — em phân loại theo nguyên nhân nhưng phòng thủ ở cả hai lớp."

2. Bài toán gốc — vì sao dữ liệu "của mình" lại không đáng tin

1. Dữ liệu đi vòng ra ngoài rồi quay về. Rất nhiều thiết kế hợp lý tạo ra vòng này: server stateless nên đẩy state xuống client (cookie, hidden field, JWT, snapshot Livewire); hệ thống bất đồng bộ nên đẩy công việc vào queue; cần nhanh nên đẩy dữ liệu vào cache; cần tích hợp nên nhận webhook; cần tự động nên CI tải công cụ từ internet. Mỗi vòng là một lần dữ liệu rời khỏi vùng mình kiểm soát. Lỗi xảy ra khi code ở đầu nhận được viết bởi người chỉ nhìn thấy đầu gửi: "giá trị này do hàm X của chính mình tạo ra, nên nó đúng".

flowchart LR
  subgraph TIN["Vùng tin cậy (server, worker)"]
    S["Code tạo dữ liệu"]
    R["Code nhận lại dữ liệu"]
  end
  subgraph NGOAI["Ngoài vùng kiểm soát"]
    B["Trình duyệt: cookie,<br/>hidden field, snapshot"]
    Q["Redis / queue / cache<br/>(ai ghi được?)"]
    W["Bên thứ ba: webhook,<br/>CDN, server cập nhật"]
  end
  S --> B --> R
  S --> Q --> R
  W --> R
  R --> D{"Kiểm toàn vẹn<br/>trước khi dùng?"}

2. Native serialization quá tiện. serialize() của PHP, ObjectInputStream của Java, pickle của Python giữ được kiểu dữ liệu, object lồng nhau, tham chiếu vòng — một dòng code là lưu được cả object graph. Cái giá: định dạng đó không chỉ chứa dữ liệu mà còn chứa chỉ dẫn tạo object thuộc class nào. Ai sửa được chuỗi là quyết định được class nào sẽ được dựng lên trong process của mình. JSON không có khả năng này — đó là lý do cheat sheet Deserialization của OWASP khuyên đổi sang "a pure data format like JSON".

3. "Mạng nội bộ thì an toàn". Redis, queue, cache thường được coi là "bên trong". Nhưng trong hệ thống nhiều node, Redis là một dịch vụ mạng mà nhiều máy cùng ghi được — app node, worker trên VPS khác, đôi khi cả công cụ admin. Nếu worker unserialize mọi thứ đọc từ queue, thì quyền "ghi được vào Redis" tương đương quyền "chạy code trên worker". Ranh giới tin cậy thật nằm ở ai ghi được, không ở "nằm trong VPC".

4. Framework binding tự động. Model::create($request->all()) hay repository.save(body) ngắn và chạy đúng với form của mình. Nhưng form là thứ phía client giữ; request có thể chứa bất kỳ field nào. Framework chỉ biết "có cột tên này" chứ không biết "người dùng được phép đặt cột này".

5. Ba khái niệm hay bị trộn: toàn vẹn, bí mật, xác thực nguồn gốc. Đây là chỗ nhiều câu trả lời phỏng vấn bị trừ điểm:

Cơ chếChứng minh được gìKhông chứng minh được gìVí dụ
Hash / checksum (SHA-256)Nội dung trùng với nội dung có hash đóAi tạo ra nó — nếu kẻ sửa file sửa được luôn chỗ đăng hash thì vô dụngshasum của Codecov uploader; MD5 trong API plugin Moodle
MAC (HMAC-SHA256)Bên có secret chung đã tạo ra đúng nội dung nàyBên nào trong số những bên có secret — mọi bên giữ secret đều ký đượcWebhook, signed URL Laravel, cookie Laravel
Chữ ký số (Ed25519, ECDSA, RSA)Chủ private key đã ký nội dung này; người kiểm chỉ cần public keyNội dung có "an toàn" không — chữ ký hợp lệ của vendor bị chiếm vẫn hợp lệ (SolarWinds)Ký image container, ký bản cập nhật, JWT RS256
Mã hoá có xác thực (AEAD, Encrypt-then-MAC)Bí mật + toàn vẹnTính mới (freshness) — bản mã cũ hợp lệ vẫn giải được nếu không có thời hạnCrypt::encrypt, ShouldBeEncrypted

Ví dụ số để nói khi bị hỏi "checksum có đủ không": file uploader 10 KB, hash SHA-256 đăng trên GitHub, file tải từ codecov.io. Kẻ tấn công chiếm được nơi lưu file trên codecov.io nhưng không chiếm repo GitHub — hai kênh khác nhau nên checksum đủ để phát hiện (và thực tế đã phát hiện). Ngược lại, Moodle tải ZIP plugin và so MD5 với giá trị nhận từ chính API thư mục plugin qua HTTPS — hash và file đến từ cùng một nguồn tin cậy, nên phép so chủ yếu bắt file hỏng hoặc bị thay giữa đường/ở mirror, không bảo vệ khi chính nguồn đó bị chiếm. Kết luận dùng được: hash chỉ mạnh bằng kênh đưa hash tới bạn; muốn độc lập với kênh thì cần chữ ký số với public key đã ghim trước.

6. Replay — đúng chữ ký nhưng đã cũ. Một webhook "thanh toán thành công" có chữ ký đúng, gửi lại lần thứ hai sau ba ngày vẫn có chữ ký đúng. Một link tải báo cáo ký đúng, chuyển tiếp cho người khác sau một tuần vẫn đúng. Toàn vẹn chỉ trả lời "có bị sửa không"; phải thêm thời hạn và định danh dùng một lần mới trả lời được "có còn được dùng không". Đó là lý do OWASP 2025 thêm chữ replay vào câu phòng thủ.

Cách nói khi phỏng vấn: "Gốc của A08 là dữ liệu đi một vòng ra ngoài rồi quay về — qua trình duyệt, qua Redis, qua bên thứ ba — và code đầu nhận tin nó vì nghĩ chính mình đã tạo ra nó. Em xử lý bằng ba câu hỏi: dữ liệu này đã rời vùng em kiểm soát chưa; nếu rồi thì em có bằng chứng toàn vẹn nào mà bên kia không giả được, hash, MAC hay chữ ký; và định dạng em dùng để đọc lại có khả năng tạo object hay chỉ chứa dữ liệu. Em cũng phân biệt rõ hash chỉ mạnh bằng kênh đưa hash tới, còn chữ ký đúng mà đã cũ thì vẫn phải bị từ chối."

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

Mỗi mục dưới đây theo cùng một khuôn: giả định sai trong code, cơ chế dẫn tới hậu quả (giải thích ở mức khái niệm), ví dụ minh hoạ có số, và sơ đồ phòng thủ của riêng cơ chế đó. Code sai/đúng nằm ở mục 4.

3.1 Deserialization dữ liệu không tin cậy — định dạng chứa "lệnh tạo object"

1. Giả định sai: "chuỗi serialize này do server tạo ra, nên giải ra sẽ được đúng object server đã cất". Thực tế chuỗi đã đi qua client, qua Redis, qua file — và định dạng native cho phép người sửa chuỗi chọn class của object được dựng lại cùng giá trị của từng thuộc tính.

2. Cơ chế ở PHP (theo PHP manual của unserialize()): khi giải ra một object, PHP dựng object thuộc class ghi trong chuỗi (autoload class đó nếu chưa nạp), gán thuộc tính theo chuỗi, rồi "will automatically attempt to call the __unserialize() or __wakeup() methods (if one exists)". Khi object bị huỷ, __destruct() chạy. Kẻ sửa chuỗi không đưa được code mới vào, nhưng chọn được class nào trong chính codebase + vendor sẽ được dựng với thuộc tính tuỳ ý, và các magic method của class đó sẽ chạy với các thuộc tính ấy. Nếu một chuỗi các class có sẵn (gọi là gadget chain — chuỗi "đồ nghề" có sẵn) nối với nhau thành một hành động nguy hiểm (ghi file, gọi hàm theo tên lấy từ thuộc tính), dữ liệu đã trở thành code. Vendor của một app Laravel điển hình có hàng nghìn class — không ai review hết được magic method của chúng, nên câu hỏi "class nào nguy hiểm" là câu hỏi sai; câu đúng là "có cho dữ liệu bên ngoài chọn class không".

3. allowed_classes giảm nhưng không xoá rủi ro. Tham số ['allowed_classes' => false] khiến mọi object bị dựng thành __PHP_Incomplete_Class thay vì class thật. Nhưng PHP manual vẫn cảnh báo thẳng: "Do not pass untrusted user input to unserialize() regardless of the options value of allowed_classes", vì việc dựng object và autoload vẫn có thể dẫn tới chạy code. Ngoài ra max_depth (mặc định 4096, có từ PHP 7.4) chỉ để chống tràn stack, không phải control bảo mật cho nội dung. Kết luận cho code review: allowed_classes là lớp thứ hai cho dữ liệu nội bộ (cache, queue), không phải giấy phép để unserialize input của người dùng.

4. Ở Java — kịch bản #4 của OWASP: một ứng dụng React gọi các microservice Spring Boot; team muốn code "immutable" nên serialize state người dùng và gửi qua lại trong mỗi request. OWASP kể kẻ tấn công nhận ra chữ ký rO0 (đầu chuỗi base64 của Java serialization) và dùng công cụ dò deserialization để chạy code trên server. Bài học thiết kế không nằm ở Java: đẩy object graph xuống client để "stateless" là mở một cửa deserialization; state nên nằm ở server (session, cache) và client chỉ giữ một ID, hoặc nếu bắt buộc giữ ở client thì là JSON có MAC/mã hoá có xác thực.

5. Ở Node.js: JSON.parse chỉ tạo object thường, mảng, chuỗi, số — không gọi constructor của class nào, không chạy hàm. Đây là lý do hệ sinh thái Node ít gặp deserialization kiểu PHP/Java. Rủi ro đến từ thư viện cố tình "hồi sinh" nhiều hơn dữ liệu: package node-serialize 0.0.4 (CVE-2017-5941, theo NVD) có thể khôi phục cả hàm từ chuỗi, nên dữ liệu không tin cậy đi qua unserialize() của nó dẫn tới chạy code. Cùng họ: nạp YAML với schema cho phép tag tạo kiểu tuỳ ý, v8.deserialize trên dữ liệu ngoài, hoặc tự viết eval/new Function để "parse" cấu hình. Sau khi JSON.parse xong vẫn còn rủi ro mass assignment (mục 3.3) nếu đem object đó gán thẳng vào entity.

6. Ví dụ minh hoạ (số giả định): một trang "bộ lọc báo cáo" lưu bộ lọc của giảng viên vào cookie bằng serialize($filters) rồi base64 — cookie dài ~300 byte, tiện vì giữ được object DateRange. Nếu cookie này nằm trong danh sách loại trừ của EncryptCookies (để JS phía client đọc được), thì bất kỳ ai cũng sửa được nó, và dòng unserialize(base64_decode(...)) ở controller là điểm nhận không kiểm. Sửa đúng: lưu bộ lọc dạng JSON (mảng thuần), validate từng field khi đọc lại; nếu cần chống sửa thì để cookie được mã hoá có xác thực (mặc định của Laravel) hoặc chuyển bộ lọc về session/DB.

flowchart TD
  IN["Chuỗi nhận được<br/>(cookie, field, cache, queue)"] --> Q1{"Đã rời vùng tin cậy<br/>hoặc ai đó ngoài app<br/>ghi được vào?"}
  Q1 -- "Có" --> F1["Không dùng native serialize:<br/>đổi sang JSON + validate"]
  Q1 -- "Không chắc" --> F2["Coi như có"]
  F2 --> F1
  Q1 -- "Không, và chứng minh được<br/>(ACL, mạng, MAC)" --> F3["Native serialize chấp nhận được<br/>+ allowed_classes danh sách cụ thể<br/>+ MAC/mã hoá có xác thực"]
  F1 --> OK["Dữ liệu chỉ là dữ liệu:<br/>không class nào được dựng theo ý bên ngoài"]
  F3 --> OK
Cách nói khi phỏng vấn: "Định dạng serialize gốc của PHP hay Java không chỉ chứa dữ liệu mà còn chứa chỉ dẫn dựng object thuộc class nào. Ai sửa được chuỗi thì chọn được class trong chính codebase và vendor của mình, và các method tự chạy khi giải ra hay khi huỷ object sẽ chạy với thuộc tính họ đặt. Nên quy tắc của em là dữ liệu từ ngoài vào chỉ đi qua định dạng chỉ chứa dữ liệu như JSON rồi validate. Tham số giới hạn class được phép chỉ là lớp thứ hai cho dữ liệu nội bộ, chính tài liệu PHP cũng nói không được dùng nó làm lý do để giải chuỗi của người dùng."

3.2 Có ký, có mã hoá — nhưng key lộ thì toàn vẹn sụp theo

1. Giả định sai: "dữ liệu đã được mã hoá/ký bằng APP_KEY nên an toàn để đưa vào unserialize". MAC và chữ ký chỉ mạnh bằng độ bí mật của key. Khi key lộ (commit nhầm .env, trang debug in biến môi trường, backup bị lộ, nhân viên cũ còn giữ), kẻ tấn công tạo được payload hợp lệ, và mọi lớp phía sau chỉ dựa vào "đã qua kiểm MAC" sẽ mở toang.

2. Sự cố thật — CVE-2018-15133 (Laravel). Theo NVD: Laravel tới 5.5.40 và 5.6.x tới 5.6.29 có thể bị chạy code từ xa do gọi unserialize trên giá trị header X-XSRF-TOKEN sau khi giải mã — điều kiện là kẻ tấn công biết application key, NVD ghi "which normally would never occur, but could happen if the attacker previously had privileged access or successfully accomplished a previous attack". CVE này có trong danh mục Known Exploited Vulnerabilities của CISA (thêm vào 16/01/2024) — tức là đã bị khai thác thật, nhiều năm sau bản vá. Bản vá 5.6.30 đổi thiết kế: cookie không còn được serialize mặc định. Trong source 13.x, EncryptCookies vẫn giữ protected static $serialize = false;.

3. Laravel 13 áp cùng bài học cho cache. Skeleton config/cache.php của 13.x có 'serializable_classes' => false với chú thích: "By default, no PHP classes will be unserialized from your cache to prevent gadget chain attacks if your APP_KEY is leaked". Upgrade guide 13.x ghi: ứng dụng cố ý lưu object PHP vào cache phải liệt kê class được phép, hoặc chuyển sang payload không phải object (mảng). Đây là thiết kế phòng thủ nhiều lớp đúng nghĩa: giả sử lớp key đã thủng, lớp định dạng vẫn đứng.

4. Ví dụ minh hoạ: một team để APP_DEBUG=true trên staging public (lỗi A02), trang lỗi lộ biến môi trường, staging và production dùng chung APP_KEY (tiện cho việc copy dữ liệu). Từ một lỗi cấu hình ở staging, mọi cookie, signed URL, job mã hoá ở production đều giả được. Hai quyết định chặn chuỗi này: mỗi môi trường một key, và không có unserialize nào phía sau MAC trên dữ liệu từng đi qua client.

flowchart TD
  K["APP_KEY lộ<br/>(debug page, .env commit,<br/>backup, key dùng chung)"] --> P["Kẻ tấn công tạo payload<br/>MAC hợp lệ"]
  P --> G1{"Phía sau MAC có<br/>unserialize object?"}
  G1 -- "Có (Laravel 5.6.29 trở về trước)" --> X["Dữ liệu thành code"]
  G1 -- "Không: JSON / serializable_classes=false" --> L["Thiệt hại giới hạn ở<br/>giả mạo dữ liệu → xoay key"]
Cách nói khi phỏng vấn: "Chữ ký hay mã hoá chỉ mạnh bằng độ bí mật của key. Laravel đã trả giá cho bài học này: bản 5.6 cũ giải mã cookie xong lại giải serialize nó, nên ai có application key là chạy được code — và lỗ hổng đó vẫn bị khai thác thật nhiều năm sau. Thiết kế sau đó bỏ serialize cookie, và bản 13 mặc định không cho giải object từ cache, với lý do ghi thẳng trong file cấu hình là phòng khi key bị lộ. Em áp dụng đúng tư duy đó: mỗi môi trường một key, và không bao giờ đặt bước tạo object ngay sau bước kiểm chữ ký."

3.3 Mass assignment — client quyết định object có những field nào

1. Giả định sai: "form chỉ có ô tên và email, nên request chỉ có tên và email". Cheat sheet Mass Assignment của OWASP định nghĩa: framework tự bind tham số request vào object cho tiện, và kẻ tấn công thêm tham số mà developer không định cho phép, ghi đè thuộc tính nhạy cảm. Tên khác: autobinding (Spring, ASP.NET), object injection (PHP). CWE-915 là một trong ba CWE OWASP nêu tên cho A08:2025.

2. Cơ chế: model/entity biết mọi cột của bảng (role, is_admin, tenant_id, price, owner_id, email_verified_at). Khi code đổ nguyên request vào ($request->all(), Object.assign(entity, body), repo.save(body)), mọi cột có tên trùng với một key trong request đều bị ghi. Không có bước "kiểm quyền" nào bị vượt qua theo nghĩa thông thường — code không hề hỏi.

3. Sự cố thật — GitHub 2012 (cheat sheet dẫn bài blog của GitHub): một người dùng lợi dụng mass assignment để gắn public key của mình vào một tổ chức bất kỳ, từ đó đẩy thay đổi vào repository của họ. Rails lúc đó cho phép gán mọi thuộc tính theo mặc định; sau sự cố, Rails chuyển sang strong parameters (allowlist ở controller).

4. Biến thể hay bị quên — state phía client của framework "full-stack". Với Livewire, public property của component được gửi xuống trình duyệt và gửi ngược lên mỗi lần tương tác. Docs Livewire nói rõ public property có thể bị người dùng sửa; #[Locked] chặn việc sửa từ phía client (sửa thì Livewire ném exception), và lưu cả model Eloquent vào property thì ID của model không bị đổi. Livewire có checksum HMAC cho snapshot (source Checksum.php dùng hash_hmac('sha256', ...) và hash_equals) — nhưng checksum chỉ chống sửa snapshot; còn cập nhật property là tính năng chính thức, nên mọi public property không khoá đều phải coi là input người dùng.

5. Ví dụ minh hoạ: trang hồ sơ học viên cho sửa name, phone. Model User có $guarded = [] (tắt bảo vệ cho tiện seed dữ liệu). Controller gọi $user->update($request->all()). Một request thêm role=lecturer → học viên thành giảng viên, thấy được bài làm của cả lớp. Cùng lỗi đó trong NestJS: DTO có 2 field nhưng ValidationPipe không bật whitelist, service gọi Object.assign(user, body) — field thứ 3 đi thẳng vào entity.

flowchart TD
  RQ["Request: name, phone + field lạ (role)"] --> V{"Lớp 1: validate theo<br/>danh sách field khai báo"}
  V -- "Laravel: validated() / safe()->only()<br/>NestJS: whitelist + forbidNonWhitelisted" --> M{"Lớp 2: model chỉ nhận<br/>field fillable"}
  V -- "field lạ" --> E1["Bỏ đi hoặc trả 400"]
  M --> S["Lớp 3: field nhạy cảm<br/>(role, owner_id, giá)<br/>do server gán tường minh"]
  S --> P["Lớp 4 (A01): policy kiểm quyền<br/>cho hành động"]
Cách nói khi phỏng vấn: "Mass assignment là khi code đổ nguyên request vào model, nên client quyết định object có những field nào. Em chặn bằng ba lớp: validate theo danh sách field khai báo và chỉ lấy phần đã validate; model có danh sách field được phép gán hàng loạt; còn field nhạy cảm như vai trò, chủ sở hữu, giá tiền thì server tự gán, không bao giờ lấy từ request. Với Livewire em coi mọi public property là input người dùng, property nào không được đổi thì khoá lại."

3.4 Cookie và state phía client dùng cho quyết định bảo mật

1. Giả định sai (CWE-565, CWE-784): "cookie do server đặt nên client không sửa được", hoặc "cookie is_premium=1 đủ để mở khoá nội dung". Cookie không mã hoá/không ký là input người dùng như mọi query string. CWE-784 nói riêng về trường hợp cookie đó quyết định một việc bảo mật (phân quyền, giá, trạng thái thanh toán).

2. Kịch bản #1 của OWASP 2025 — chức năng web từ nguồn không tin cậy: công ty dùng nhà cung cấp hỗ trợ bên ngoài và cho tiện, trỏ DNS support.myCompany.com sang myCompany.SupportProvider.com. Hệ quả: mọi cookie đặt cho domain myCompany.com, kể cả cookie xác thực, được trình duyệt gửi tới hạ tầng của nhà cung cấp; ai có quyền ở đó lấy được cookie của mọi người dùng đã ghé support.myCompany.com và chiếm phiên. Lỗi gốc không phải ở nhà cung cấp mà ở chỗ phạm vi cookie (thuộc tính Domain) rộng hơn vùng tin cậy thật. Phòng thủ: cookie phiên host-only (không đặt Domain, hoặc dùng tiền tố __Host- — chi tiết ở Bài 7), và subdomain trỏ sang bên thứ ba thì coi là một origin khác.

3. Hidden field và localStorage cùng họ. Giá sản phẩm trong hidden field, user_id trong body, cờ isAdmin trong localStorage để ẩn/hiện menu — tất cả hợp lệ cho giao diện, sai khi server dùng chúng để quyết định. Quy tắc: client chỉ gửi ý định (muốn mua sản phẩm 42, số lượng 2); server tự tra giá, tự lấy user từ phiên.

flowchart LR
  C["Giá trị phía client<br/>(cookie, hidden field,<br/>localStorage, body)"] --> Q{"Dùng để quyết định<br/>quyền / giá / trạng thái?"}
  Q -- "Không (chỉ hiển thị)" --> UI["Chấp nhận"]
  Q -- "Có" --> S1["Lấy từ server:<br/>session, DB, tra giá"]
  Q -- "Bắt buộc đi qua client" --> S2["Mã hoá có xác thực / MAC<br/>+ thời hạn + host-only cookie"]
Cách nói khi phỏng vấn: "Cookie hay hidden field không ký thì cũng là input người dùng như query string. Em chỉ cho client gửi ý định, còn giá, quyền, trạng thái thì server tự tra. Kịch bản mới của OWASP năm nay rất đáng nhớ: trỏ một subdomain sang nhà cung cấp hỗ trợ, thế là cookie đăng nhập của domain chính bay sang hạ tầng của họ. Nên cookie phiên của em luôn là host-only, không đặt domain chung cho cả họ subdomain."

3.5 Dữ liệu vượt ranh giới giữa các hệ thống — ký cái gì, kiểm theo thứ tự nào

1. Giả định sai (CWE-345, CWE-353): "request tới endpoint webhook bí mật thì chắc là của cổng thanh toán"; "link tải có ID báo cáo khó đoán là đủ". URL không phải bí mật lâu dài (nằm trong log, lịch sử trình duyệt, header Referer); IP allowlist đổi theo hạ tầng của bên gửi.

2. Ba quyết định thiết kế khi ký dữ liệu qua ranh giới (tổng hợp từ cheat sheet, docs NestJS mục Webhooks, và cách Laravel làm signed URL):

  1. Ký đúng byte đã gửi. Chữ ký webhook tính trên body thô. Nếu framework parse JSON rồi code serialize lại để tính HMAC, thứ tự key hay khoảng trắng khác đi là chữ ký sai — và team dễ "sửa" bằng cách tắt kiểm. Docs NestJS bật rawBody: true với lý do ghi trong comment ví dụ: "incoming webhooks are verified on the bytes that were signed, not on a re-serialized body".
  2. Ký cả ngữ cảnh, không chỉ nội dung. Thêm timestamp và ID sự kiện vào chuỗi được ký (ví dụ id.timestamp.body như scheme "standard" trong docs NestJS), để chặn phát lại và để một chữ ký của sự kiện này không dùng được cho sự kiện khác. Signed URL của Laravel ký cả URL đầy đủ (mặc định gồm cả domain) cùng tham số expires.
  3. Kiểm trước, parse sau. Kiểm chữ ký → kiểm thời hạn (docs NestJS dùng dung sai 5 phút theo cả hai chiều) → kiểm ID chưa xử lý → mới parse và xử lý. So chữ ký bằng hàm constant-time (hash_equals, crypto.timingSafeEqual) — chi tiết ở Bài 4 mục 4.4. Hỗ trợ nhiều secret cùng lúc để xoay key không gián đoạn.

3. Ví dụ minh hoạ có số: cổng đối tác gửi webhook "điểm đã đồng bộ" ~2.000 lần/ngày. Dung sai 5 phút nghĩa là kho ID đã xử lý chỉ cần giữ tối thiểu 5 phút cộng biên (thực tế giữ 24 giờ cho chắc) — ~2.000 khoá Redis, không đáng kể. Không có timestamp thì kho ID phải giữ mãi mãi mới chặn được phát lại; đó là lý do timestamp và ID đi cặp với nhau. KB đã có một lab thật cho đúng mô hình này (cổng giờ lệch 5 phút → HMAC trên ts.body → message_id chưa có trong bảng) ở bài Đồng bộ điểm chuyên cần giữa các trường.

sequenceDiagram
  participant P as Bên gửi (cổng thanh toán)
  participant A as API nhận webhook
  participant R as Redis (ID đã xử lý)
  P->>A: POST body thô + header id, timestamp, chữ ký
  A->>A: 1. HMAC(id.timestamp.body_thô) so constant-time
  A->>A: 2. Lệch giờ ≤ 5 phút
  A->>R: 3. SET id NX EX 86400
  R-->>A: OK (lần đầu) / nil (đã xử lý)
  A->>A: 4. Lúc này mới parse JSON + validate DTO
  A-->>P: 2xx (kể cả khi trùng, để bên gửi ngừng gửi lại)
Cách nói khi phỏng vấn: "Khi dữ liệu vượt ranh giới giữa hai hệ thống, em ký đúng những byte đã gửi chứ không ký lại sau khi parse, ký kèm thời điểm và mã sự kiện, và kiểm theo thứ tự: chữ ký trước, rồi thời hạn, rồi mã sự kiện chưa xử lý, cuối cùng mới parse. Thời điểm và mã sự kiện phải đi cặp: có thời hạn năm phút thì kho mã đã xử lý chỉ cần giữ ngắn; không có thời hạn thì phải giữ mãi mới chặn được gửi lại."

3.6 Payload trong cache và queue — "nội bộ" không có nghĩa là đáng tin

1. Giả định sai: "Redis là của mình, chỉ app ghi vào". Trong hệ thống nhiều node, Redis được ghi bởi mọi app node, mọi worker, đôi khi cả script vận hành, và (nếu cấu hình sai, A02) bởi bất kỳ ai tới được cổng của nó.

2. Queue của Laravel — đọc source 13.x. CallQueuedHandler::getCommand() lấy chuỗi command trong payload: nếu chuỗi bắt đầu bằng O: (object PHP serialize thường) thì gọi thẳng unserialize($data['command']) — không có allowed_classes; nếu không, giải mã bằng Encrypter rồi mới unserialize. Nghĩa là: với job thường, ai ghi được vào list queue trong Redis thì quyết định được object nào được dựng trong worker. Job có ShouldBeEncrypted được mã hoá có xác thực trước khi đẩy vào queue — docs gọi đây là bảo vệ "privacy and integrity" của dữ liệu job; payload bị sửa sẽ trượt bước kiểm MAC trước khi tới unserialize.

3. Cache của Laravel 13. RedisStore::unserialize() dùng allowed_classes khi cấu hình serializable_classes khác null. Skeleton mới đặt false (không class nào). Bẫy khi nâng cấp: ứng dụng nâng từ 12 lên 13 mà giữ config/cache.php cũ thì không có khoá này → CacheManager::getSerializableClasses() trả null → hành vi cũ, unserialize không giới hạn. Phải chủ động thêm khoá khi nâng cấp (đọc source; chưa chạy thật).

4. Node/BullMQ khác về bản chất. Docs BullMQ: job data được JSON.stringify trước khi vào Redis và JSON.parse ở worker; class instance mất prototype. Không có bước dựng object theo class → không có dạng tấn công gadget chain. Nhưng toàn vẹn nội dung vẫn là vấn đề: ai ghi được vào Redis vẫn đổi được userId trong job "gửi chứng chỉ". Phòng thủ: Redis có ACL/mật khẩu và chỉ nghe trên mạng nội bộ, worker validate job.data như input, và với job quan trọng thì ký payload hoặc chỉ truyền ID rồi tra lại DB.

5. Toàn vẹn theo thời gian — khi deploy. Payload cache/job do bản cũ ghi, bản mới đọc (hoặc ngược lại khi rollback). Object serialize mà class đổi thuộc tính thì giải ra sai hoặc lỗi — bài Distributed cache khi rollback blue-green đã lab hiện tượng "shape drift" này. Định dạng JSON có version field vừa an toàn hơn vừa dễ tương thích ngược hơn — hai lợi ích cùng một quyết định.

flowchart TD
  W["Ai ghi được vào Redis?<br/>app node, worker, script, cổng mở?"] --> J{"Payload là gì?"}
  J -- "PHP serialize thường<br/>(job Laravel mặc định)" --> R1["Worker unserialize không giới hạn class<br/>→ quyền ghi Redis = quyền chạy code"]
  J -- "ShouldBeEncrypted" --> R2["MAC sai → dừng trước unserialize"]
  J -- "JSON (BullMQ, mảng thuần)" --> R3["Không dựng class<br/>→ còn rủi ro sửa nội dung: validate"]
  R1 --> D["Phòng thủ: Redis ACL + mạng riêng,<br/>job chỉ mang ID, mã hoá job nhạy cảm"]
  R2 --> D
  R3 --> D
Cách nói khi phỏng vấn: "Em không coi Redis là vùng tin cậy chỉ vì nó nằm trong mạng nội bộ. Đọc source Laravel thì job thường được worker giải serialize không giới hạn class, nghĩa là ai ghi được vào Redis là quyết định được object nào dựng lên trong worker. Nên em khoá Redis bằng ACL và mạng riêng, job chỉ mang ID rồi tra lại DB, job nhạy cảm thì bật mã hoá để có kiểm toàn vẹn. Với cache trên Laravel 13 em kiểm khoá cấu hình giới hạn class, vì app nâng cấp từ bản cũ không tự có khoá đó."

3.7 Code tải về lúc build hoặc lúc chạy mà không kiểm

1. Giả định sai (CWE-494, 829, 830): "URL này luôn trả về đúng file đó". URL là con trỏ có thể đổi nội dung; chỉ digest (hash nội dung) hoặc chữ ký mới ghim được nội dung.

2. Bốn điểm nhận code điển hình và cách kiểm của từng điểm:

Điểm nhậnDạng saiKiểm toàn vẹn phù hợp
Script công cụ trong CITải script cài đặt từ URL rồi chạy ngay trong jobGhim phiên bản + so SHA-256 lấy từ kênh khác (repo, release note), hoặc dùng image công cụ ghim digest; tốt hơn: kiểm chữ ký của vendor
Script front-end từ CDN<script src> trỏ CDN, không integrityTự host qua bundler; hoặc SRI integrity="sha384-…" + crossorigin="anonymous" (chi tiết Bài 3 mục 4.9)
Plugin / bản cập nhật cài qua giao diện adminServer production tự tải ZIP và ghi vào thư mục codeCài qua repo + build như code của mình; tắt cài từ web
Auto-update ở client/thiết bịTải và áp bản cập nhật không ký (kịch bản #2 OWASP: firmware router, set-top box)Chữ ký số với public key ghim trong bản đang chạy; chống hạ cấp phiên bản

3. SRI hoạt động thế nào (theo MDN): trình duyệt tính hash của nội dung tải về và so với các giá trị trong thuộc tính integrity (cho phép sha256, sha384, sha512); khớp một giá trị là chạy, không khớp thì "refuse to load the resource" và trả network error. Request cross-origin có integrity bắt buộc đi theo CORS — phải có crossorigin, nếu không request ở chế độ no-cors sẽ luôn thất bại. MDN cũng mô tả header Integrity-Policy để trình duyệt chặn script không có integrity. Giới hạn: SRI chỉ dùng được cho file tĩnh có nội dung cố định; dịch vụ trả nội dung khác nhau theo trình duyệt (kiểu polyfill.io) không thể ghim hash — khi đó tự host là lối ra duy nhất.

4. Moodle — đọc source MOODLE_405_STABLE. core\update\code_manager::get_remote_plugin_zip($url, $md5) tải ZIP plugin và so md5_file() với giá trị MD5 kỳ vọng do caller cung cấp (lấy từ API thư mục plugin); lệch là bỏ. Như đã phân tích ở mục 2, đây là kiểm cùng kênh. Quan trọng hơn: config-dist.php có cờ $CFG->disableupdateautodeploy = true; để "completely disable the installation of plugins ... via the server administration web interface". Với hệ thống nhiều app node chạy code từ image/bản build, cài plugin từ web trên một node vừa phá toàn vẹn (code chạy ≠ code đã review), vừa gây lệch giữa các node.

flowchart TD
  U["URL / tag / tên package"] --> Q{"Đã ghim theo nội dung?"}
  Q -- "Không: chỉ ghim tên/tag" --> B["Nội dung có thể đổi<br/>mà không ai biết"]
  Q -- "Digest SHA-256 từ kênh khác" --> C1["Phát hiện sửa file<br/>(Codecov: khách hàng so shasum)"]
  Q -- "Chữ ký số + public key ghim sẵn" --> C2["Độc lập với kênh tải<br/>(bản cập nhật, image)"]
  C1 --> N["Vẫn còn: nguồn ký bị chiếm → A03"]
  C2 --> N
Cách nói khi phỏng vấn: "Một URL hay một tag chỉ là con trỏ, nội dung đằng sau đổi được mà mình không biết. Muốn ghim nội dung thì phải có hash hoặc chữ ký. Hash thì phải lấy từ kênh khác với kênh tải file, giống khách hàng của Codecov đã so mã băm đăng trên GitHub và phát hiện ra vụ việc. Chữ ký số thì độc lập với kênh, nên dùng cho bản cập nhật và image. Với Moodle em tắt cài plugin từ giao diện web, plugin vào hệ thống qua repo và bản build như code của mình."

3.8 Pipeline CI/CD và artifact — toàn vẹn từ commit tới production

1. Giả định sai: "image đang chạy trên production là image được build từ commit đã review". Giữa hai điểm đó có: runner build (có thể chạy job từ nhánh chưa review), registry (ai push được tag gì), bước deploy (kéo theo tag có thể bị ghi đè), và máy chủ (ai sửa được file trên đĩa). OWASP yêu cầu "proper segregation, configuration, and access control" cho pipeline và "a review process for code and configuration changes".

2. Chuỗi kiểm soát toàn vẹn đầy đủ (tổng hợp OWASP + mô hình SLSA mà Bài 3 đã giới thiệu):

  1. Nguồn: nhánh chính và tag release được bảo vệ; merge cần review; người viết code không tự duyệt (tách nhiệm vụ).
  2. Build: build một lần từ commit đã review, trên runner riêng cho nhánh được bảo vệ (job từ merge request của nhánh lạ không chạm được secret deploy và không push được vào registry production).
  3. Artifact: ghi lại digest của image (sha256:…) ngay sau build; nếu có hạ tầng ký thì ký image và kèm provenance (ai build, từ commit nào).
  4. Deploy: kéo image theo digest, không theo tag latest hay tag có thể bị ghi đè; nếu có chữ ký thì kiểm chữ ký trước khi chạy.
  5. Runtime: filesystem của code chỉ đọc với user chạy PHP-FPM/Node; không có đường nào để ứng dụng tự ghi code của chính nó.

3. Search path (CWE-426, 427) là một biến thể nằm ở bước 5: lệnh hoặc thư viện được tìm theo danh sách thư mục (PATH, include_path, thứ tự tìm module), và một thư mục ghi được nằm trước thư mục hệ thống. Ví dụ minh hoạ: container chạy job CI có PATH bắt đầu bằng thư mục làm việc của job — một file tên trùng công cụ trong repo sẽ được gọi thay công cụ thật. Phòng thủ: gọi bằng đường dẫn tuyệt đối trong script nhạy cảm, không đưa thư mục ghi được vào đầu PATH, image runtime không chứa công cụ build.

4. Mã độc nhúng (CWE-506, 509) trong A08 được nhìn từ phía "artifact có còn đúng là thứ đã review không": build từ commit đã review + artifact bất biến + kiểm digest khi deploy là cách phát hiện code lạ chen vào sau review. Mã độc đến từ dependency thì thuộc phạm vi Bài 3.

Với dự án dùng GitLab CI, KB đã có bài thực hành pipeline Deploy Laravel bằng GitLab CI/CD + Docker và git workflow + promote bằng tag; mục 4.7 dưới đây chỉ thêm các điểm kiểm toàn vẹn vào khuôn đó.

flowchart TD
  C["Commit đã review<br/>(nhánh/tag được bảo vệ)"] --> B["Build 1 lần trên runner<br/>của nhánh được bảo vệ"]
  B --> D["Ghi digest sha256<br/>(+ ký, provenance nếu có)"]
  D --> S["Staging kéo theo digest"]
  S --> P["Production kéo cùng digest<br/>+ kiểm chữ ký"]
  P --> RO["Runtime: code read-only,<br/>tắt cài plugin từ web"]
  X["Tag bị ghi đè / push tay<br/>vào registry"] -. "không có tác dụng vì<br/>deploy theo digest" .-> P
Cách nói khi phỏng vấn: "Câu em tự hỏi là image đang chạy trên production có đúng là image build từ commit đã review không. Em giữ chuỗi đó bằng năm mắt xích: nhánh và tag được bảo vệ, có review; build một lần trên runner riêng của nhánh được bảo vệ; ghi lại mã băm nội dung của image, ký nếu có hạ tầng; deploy kéo theo mã băm chứ không theo tên tag; và lúc chạy thì code chỉ đọc, ứng dụng không tự ghi code của chính nó được."

3.9 Hình động: một payload đi qua năm cổng kiểm toàn vẹn

Hình dưới là luồng phòng thủ, áp dụng chung cho webhook, signed URL, cookie mang state, job trong queue: payload hợp lệ (thẻ xanh) đi qua năm cổng theo đúng thứ tự mục 4 cài đặt; payload bị sửa một byte (thẻ đỏ) dừng ngay ở cổng 1 — trước khi bất kỳ dòng parse nào chạy. Thứ tự là điểm chính: kiểm chữ ký trước khi parse, vì parse trước nghĩa là đã cho dữ liệu chưa tin được chạy qua code của mình.

Một payload đi qua năm cổng kiểm toàn vẹn Một thẻ màu xanh dương tên "payload hợp lệ" đi từ trái sang phải qua năm hộp. Hộp 1, kiểm chữ ký trên đúng byte nhận được, so sánh constant-time: hiện "HMAC khớp key hiện tại hoặc key cũ đang xoay". Hộp 2, còn hạn và chưa dùng: hiện "timestamp lệch 12 giây, qua; id mới, ghi nhận". Hộp 3, parse bằng định dạng chỉ chứa dữ liệu: hiện "JSON ra mảng, chuỗi, số; không dựng class nào". Hộp 4, allowlist field: hiện "giữ 4 field khai báo, bỏ field lạ". Hộp 5, xử lý: hiện "role, giá, owner_id do server gán". Sau đó một thẻ màu đỏ tên "payload bị sửa 1 byte" xuất hiện dưới hộp 1, và một dấu gạch đỏ cùng dòng chữ "dừng ở cổng 1: 401 + log verification-failed, không parse". Kiểm toàn vẹn = 5 cổng theo thứ tự; cổng 1 chạy trước mọi bước parse 1. Kiểm chữ ký trên đúng byte nhận được so sánh constant-time 2. Còn hạn + mới timestamp ±5 phút id chưa xử lý 3. Parse chỉ-dữ-liệu JSON, không unserialize không dựng class 4. Allowlist field DTO / fillable field lạ: bỏ hoặc 400 5. Xử lý field nhạy cảm do server gán HMAC khớp key hiện tạihoặc key cũ đang xoaysai → 401, không parse ts lệch 12 s → quaid mới → ghi nhậnid trùng → 2xx, bỏ qua mảng, chuỗi, sốkhông gọi code nàoJSON lỗi → 400 giữ 4 field khai báobỏ field lạkiểu, độ dài, enum role, giá, owner_idlấy từ server / DB+ policy kiểm quyền (A01) payload hợp lệ payload bị sửa 1 byte dừng ở cổng 1: 401 + log verification-failed — không có dòng parse nào chạy Áp dụng cho: webhook, signed URL, cookie mang state, job trong queue. Với signed URL, cổng 2 là tham số expires; cổng 4 là route + validate. Số liệu trong hình (±5 phút, lệch 12 s, 4 field) là ví dụ minh hoạ — không phải giá trị mặc định của framework.
Hình 1. Năm cổng kiểm toàn vẹn. Cổng 1 chứng minh payload do bên có key tạo ra và không bị sửa; cổng 2 chặn phát lại; cổng 3 bảo đảm dữ liệu không điều khiển được việc tạo object; cổng 4 chặn mass assignment; cổng 5 giữ các giá trị quyết định ở phía server. Hình tôn trọng prefers-reduced-motion: khi tắt hoạt ảnh, hiện trạng thái cuối.
Cách nói khi phỏng vấn: "Mọi dữ liệu quay về từ bên ngoài, dù là webhook, link ký, cookie hay job trong queue, em cho đi qua năm cổng theo thứ tự. Kiểm chữ ký trên đúng những byte nhận được, so sánh thời gian hằng. Kiểm còn hạn và chưa dùng. Lúc đó mới parse, và parse bằng định dạng chỉ chứa dữ liệu. Rồi giữ lại đúng các field đã khai báo. Cuối cùng các giá trị quyết định như quyền, giá, chủ sở hữu thì server tự gán. Thứ tự quan trọng nhất là kiểm chữ ký trước khi parse."

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

Code dưới đây là mẫu rút gọn để đọc trong phỏng vấn, không phải module hoàn chỉnh. API của Laravel 13, NestJS, Livewire, BullMQ đã tra qua Context7 (docs chính thức); một số hàm được đối chiếu thêm với source laravel/framework nhánh 13.x (Validator::valid(), Middleware::encryptCookies(), Request::enum(), CallQueuedHandler, RedisStore). Tên bảng, tên header, con số (5 phút, 30 phút, 255 ký tự) là ví dụ minh hoạ cho LMS.

4.1 Không giải serialize dữ liệu từ ngoài: JSON + validate, object do code dựng

// ===== SAI (Laravel) — state bộ lọc giữ ở client bằng serialize =====
// bootstrap/app.php: $middleware->encryptCookies(except: ['report_filters']);  // để JS đọc được
public function index(Request $request)
{
    $raw     = $request->cookie('report_filters');
    $filters = $raw ? unserialize(base64_decode($raw)) : new ReportFilters();   // chuỗi quyết định class
    // ...
}

// ===== ĐÚNG (Laravel 13) — JSON thuần, validate từng field =====
public function index(Request $request)
{
    $raw  = $request->cookie('report_filters');      // KHÔNG nằm trong except → được mã hoá có xác thực
    $data = [];
    if (is_string($raw) && strlen($raw) <= 2048) {
        try {
            $data = json_decode($raw, true, 8, JSON_THROW_ON_ERROR);   // chỉ ra mảng/chuỗi/số, sâu tối đa 8
        } catch (\JsonException) {
            $data = [];
        }
    }

    $valid = Validator::make(is_array($data) ? $data : [], [
        'course_id' => ['nullable', 'integer'],
        'from'      => ['nullable', 'date'],
        'to'        => ['nullable', 'date', 'after_or_equal:from'],
        'status'    => ['nullable', Rule::in(['draft', 'submitted', 'graded'])],
    ])->valid();                                       // giữ field hợp lệ, bỏ phần còn lại

    $filters = ReportFilters::fromArray($valid);      // object do CODE dựng, class cố định
    // ...
}
Cách nói khi phỏng vấn: "Bản sai lưu bộ lọc bằng serialize gốc của PHP trong một cookie để ngoài danh sách mã hoá, nên ai cũng sửa được chuỗi, và chuỗi đó quyết định class nào được dựng trên server. Bản đúng lưu JSON, giới hạn kích thước và độ sâu, validate từng field, rồi chính code của em dựng object với class cố định. Cookie vẫn để framework mã hoá có xác thực, nên sửa một byte là framework bỏ cookie đó, không tới được code của em."

Bốn chi tiết khi bị hỏi sâu:

  1. Vì sao không chỉ thêm allowed_classes. PHP manual cấm dùng unserialize cho input không tin cậy bất kể allowed_classes. Hơn nữa nếu phải liệt kê class thì đã quay về đúng việc JSON + fromArray làm, chỉ là ít an toàn hơn.
  2. Validator::valid() khác validated(). validated() ném exception khi có lỗi — hợp với form; valid() trả phần dữ liệu hợp lệ — hợp với state phụ như bộ lọc, nơi một field hỏng không nên làm hỏng cả trang. Cả hai chỉ trả các key có trong rules, nên field lạ tự rơi.
  3. Giới hạn trước khi parse. 2 KB và độ sâu 8 là ví dụ; ý là không để một cookie/field quyết định lượng CPU và RAM bỏ ra để parse (độ sâu mặc định của json_decode là 512).
  4. State quan trọng thì đừng để ở client. Bộ lọc báo cáo thì được; "đang ở bước 3 của quy trình nộp bài, đã thanh toán" thì để ở session/DB, client chỉ giữ ID.
// ===== SAI (NestJS) — thư viện "hồi sinh" hàm từ chuỗi =====
import * as serialize from 'node-serialize';      // CVE-2017-5941: unserialize() khôi phục được cả hàm

@Get('reports')
list(@Req() req: Request) {
  const state = serialize.unserialize(Buffer.from(req.cookies.state, 'base64').toString());
  // ...
}

// ===== ĐÚNG (NestJS 11) — JSON.parse + DTO + class-validator =====
export class ReportFiltersDto {
  @IsOptional() @IsInt() courseId?: number;
  @IsOptional() @IsISO8601() from?: string;
  @IsOptional() @IsISO8601() to?: string;
  @IsOptional() @IsIn(['draft', 'submitted', 'graded']) status?: string;
}

async parseFilters(raw: unknown): Promise<ReportFiltersDto> {
  let data: unknown = {};
  if (typeof raw === 'string' && raw.length <= 2048) {        // giới hạn trước khi parse
    try { data = JSON.parse(raw); } catch { data = {}; }       // JSON.parse: chỉ object thường, không gọi constructor
  }
  const dto = plainToInstance(ReportFiltersDto, data);          // class do CODE chọn
  const errors = await validate(dto, { whitelist: true, forbidNonWhitelisted: true });
  if (errors.length) throw new BadRequestException('Invalid filters');
  return dto;
}
Cách nói khi phỏng vấn: "Ở Node, phân tích JSON chuẩn chỉ tạo ra object thường, không gọi hàm khởi tạo của class nào, nên nguy cơ không nằm ở bản thân JSON mà ở những thư viện cố tình khôi phục cả hàm từ chuỗi, có hẳn một CVE cho thư viện như vậy. Bản đúng của em dùng JSON chuẩn, giới hạn kích thước, rồi đổ vào một DTO do code chọn và validate với chế độ chỉ giữ field đã khai báo, có field lạ là từ chối."
flowchart TD
  A["Chuỗi từ client"] --> L{"≤ 2 KB?"}
  L -- "không" --> Z["Bỏ, dùng mặc định"]
  L -- "có" --> J["JSON.parse / json_decode<br/>(chỉ ra dữ liệu)"]
  J --> V["Validate theo rules / DTO<br/>field lạ: bỏ hoặc 400"]
  V --> O["Code dựng object<br/>class cố định"]

4.2 Cache và queue: mảng thuần, giới hạn class, job chỉ mang ID, mã hoá job nhạy cảm

// ===== SAI (Laravel) =====
// config/cache.php giữ từ bản 12: không có 'serializable_classes' → unserialize không giới hạn class
Cache::put("dashboard:{$userId}", $statsObject, 600);           // cất object vào Redis

class IssueCertificate implements ShouldQueue                    // payload là object serialize thường (bắt đầu bằng "O:")
{
    use Queueable;
    public function __construct(public array $student, public string $nationalId, public float $grade) {}
    // worker tin $grade trong payload để quyết định cấp chứng chỉ
}

// ===== ĐÚNG (Laravel 13) =====
// config/cache.php
'serializable_classes' => false,                                  // không class nào được giải ra từ cache

Cache::put("dashboard:{$userId}", $stats->toArray(), 600);       // cache mảng thuần
$stats = DashboardStats::fromArray(Cache::get("dashboard:{$userId}", []));

class IssueCertificate implements ShouldQueue, ShouldBeEncrypted  // mã hoá có xác thực trước khi vào queue
{
    use Queueable;
    public function __construct(public int $enrolmentId) {}       // chỉ mang ID

    public function handle(Certificates $certs): void
    {
        $enrolment = Enrolment::with('student')->findOrFail($this->enrolmentId);
        if (! $enrolment->hasPassed()) {                          // điều kiện quyết định tra lại từ DB
            return;
        }
        $certs->issue($enrolment);
    }
}
Cách nói khi phỏng vấn: "Bản sai có hai vấn đề. Cache cất cả object trong khi file cấu hình cũ không giới hạn class được giải ra. Job thì mang cả điểm số trong payload và worker tin luôn con số đó, trong khi payload nằm trong Redis và ai ghi được Redis là sửa được. Bản đúng cache mảng thuần với giới hạn class tắt hẳn, job chỉ mang mã bản ghi, worker tra lại điều kiện từ database, và job được mã hoá nên sửa payload là bị loại ở bước kiểm toàn vẹn trước khi tới bước giải serialize."

Ba điểm cần nói thêm:

  1. ShouldBeEncrypted không thay cho khoá Redis. Nó chống sửa và đọc lén payload, nhưng ai ghi được Redis vẫn xoá job, đẩy job cũ (đã mã hoá hợp lệ) vào lại — tức là phát lại. Job phải idempotent (chạy hai lần cùng kết quả), và Redis phải có ACL + chỉ nghe mạng riêng (Bài 2).
  2. Chi phí mã hoá job. Mỗi job thêm một lần AES + HMAC khi đẩy và khi lấy ra — micro giây so với thời gian chạy job (gửi email, tạo PDF). Với 40.000 job dồn cuối hạn, đây không phải nút thắt; nút thắt là số worker (xem Scale out queue worker). Ước lượng, chưa đo.
  3. Khi bật serializable_classes => false trên app đang chạy. Cache cũ chứa object sẽ không giải ra được thành object thật. Laravel có Cache::handleUnserializableClassUsing() để đăng ký callback khi gặp class không được phép (đọc source 13.x) — dùng để log và đếm trong lúc chuyển đổi; cách gọn nhất là đổi tiền tố key cache cùng lần deploy để bản mới không đọc payload cũ.
// ===== SAI (NestJS + BullMQ) =====
await this.queue.add('issue-certificate', { student, nationalId, grade });   // quyết định nằm trong Redis
// worker
async process(job: Job) {
  await this.certs.issue(job.data.student, job.data.grade);                   // tin job.data
}

// ===== ĐÚNG (NestJS + BullMQ) =====
await this.queue.add('issue-certificate', { enrolmentId }, { jobId: `cert:${enrolmentId}` });
// worker
async process(job: Job<{ enrolmentId: number }>) {
  const id = Number(job.data?.enrolmentId);
  if (!Number.isSafeInteger(id) || id <= 0) throw new UnrecoverableError('bad payload');  // không retry
  const enrolment = await this.enrolments.findPassedOrFail(id);              // điều kiện tra lại DB
  await this.certs.issue(enrolment);
}
Cách nói khi phỏng vấn: "BullMQ lưu dữ liệu job dạng JSON nên không có chuyện dựng class theo ý người sửa, nhưng nội dung vẫn sửa được nếu ai đó ghi được Redis. Nên em coi dữ liệu job như input: chỉ mang mã bản ghi, kiểm kiểu trước khi dùng, payload sai thì đánh dấu lỗi không thử lại, và mọi điều kiện quyết định như đã đạt hay chưa thì tra lại database."
flowchart TD
  P["Producer"] -- "chỉ ID (+ mã hoá nếu nhạy cảm)" --> R["Redis queue<br/>ACL + mạng riêng"]
  R --> W["Worker"]
  W --> C{"Payload đúng kiểu?<br/>(MAC đúng nếu mã hoá)"}
  C -- "không" --> F["Fail, không retry, log"]
  C -- "có" --> DB["Tra lại DB: điều kiện,<br/>quyền, trạng thái"]
  DB --> I["Xử lý idempotent"]

4.3 Mass assignment: allowlist ở request, ở model, và field nhạy cảm gán tường minh

// ===== SAI (Laravel) =====
class User extends Model
{
    protected $guarded = [];                                     // tắt bảo vệ "cho tiện seed"
}

public function update(Request $request)
{
    $request->user()->update($request->all());                 // role, email_verified_at... đều ghi được
}

// ===== ĐÚNG (Laravel 13) =====
#[Fillable(['name', 'phone'])]
class User extends Model { /* ... */ }

public function update(UpdateProfileRequest $request): RedirectResponse
{
    $request->user()->update($request->validated());           // chỉ field khai báo trong rules()
    return back();
}

// Đổi vai trò là hành động riêng, route riêng, policy riêng
public function changeRole(ChangeRoleRequest $request, User $user): RedirectResponse
{
    Gate::authorize('changeRole', $user);                         // A01
    $user->role = $request->enum('role', Role::class);          // gán tường minh, không qua fill()
    $user->save();
    return back();
}

// AppServiceProvider::boot() — bắt lỗi sớm ở local/staging
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Cách nói khi phỏng vấn: "Bản sai tắt hết bảo vệ của model rồi đổ nguyên request vào, nên request nào thêm field vai trò là học viên tự lên giảng viên. Bản đúng có hai danh sách cho phép: request chỉ trả về field đã khai báo luật, model chỉ cho gán hàng loạt hai field. Đổi vai trò là một hành động riêng, có kiểm quyền riêng, và giá trị được gán từng dòng. Ở môi trường dev em bật chế độ báo lỗi khi có field bị bỏ qua, để lập trình viên thấy ngay thay vì dữ liệu lặng lẽ biến mất."

Ba chi tiết:

  1. Hai lớp không thừa. validated() bảo vệ controller này; #[Fillable] bảo vệ mọi controller khác, job, seeder gọi fill() trên cùng model. Một lớp là quy ước, hai lớp là phòng thủ.
  2. Vì sao không bật preventSilentlyDiscardingAttributes ở production. Ở production, field lạ bị bỏ là hành vi mong muốn; ném exception sẽ biến một request có field thừa thành lỗi 500. Ở local/staging, exception giúp phát hiện form gửi field mà model không nhận.
  3. $guarded (danh sách cấm) yếu hơn $fillable (danh sách cho phép). Thêm cột mới vào bảng thì danh sách cấm không tự cập nhật — cột mới mặc định gán được. Cheat sheet OWASP liệt kê cả hai, nhưng allowlist và DTO là phương án được nêu như cách tiếp cận kiến trúc.
// ===== SAI (Livewire) =====
class EditSubmission extends Component
{
    public $submissionId;            // client đổi được → sửa bài của người khác (nếu không kiểm quyền)
    public $score;                   // property công khai = input người dùng
    public function save() { Submission::find($this->submissionId)->update(['score' => $this->score]); }
}

// ===== ĐÚNG (Livewire) =====
class EditSubmission extends Component
{
    #[Locked]
    public int $submissionId;                                      // client sửa → Livewire ném exception
    public string $answer = '';                                    // không khoá = input → validate

    public function save(): void
    {
        $this->validate(['answer' => ['required', 'string', 'max:5000']]);
        $submission = Submission::findOrFail($this->submissionId);
        Gate::authorize('update', $submission);                    // khoá không thay cho kiểm quyền
        $submission->update(['answer' => $this->answer]);       // điểm không bao giờ đến từ property client
    }
}
Cách nói khi phỏng vấn: "Với Livewire em coi mọi public property là dữ liệu người dùng gửi lên, vì chính tài liệu Livewire nói chúng sửa được từ trình duyệt. Mã bản ghi thì em khoá lại để client không đổi được, nhưng vẫn kiểm quyền vì khoá chỉ chống sửa phía client chứ không nói người này có được sửa bản ghi này không. Còn điểm số thì không bao giờ là property mà client gửi lên."
// ===== SAI (NestJS) =====
@Patch('me')
async updateMe(@Req() req, @Body() body: any) {
  const user = await this.users.findOneByOrFail({ id: req.user.id });
  Object.assign(user, body);                                       // role, tenantId... đều ghi được
  return this.users.save(user);
}

// ===== ĐÚNG (NestJS 11) =====
// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));

export class UpdateProfileDto {
  @IsString() @Length(1, 100) name: string;
  @IsOptional() @IsPhoneNumber('VN') phone?: string;
}

@Patch('me')
async updateMe(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {   // CurrentUser: decorator tự viết
  await this.users.update({ id: user.id }, { name: dto.name, phone: dto.phone }); // map tường minh từng field
}
Cách nói khi phỏng vấn: "Ở NestJS em bật pipe validate toàn cục với chế độ chỉ giữ field có khai báo và từ chối request có field lạ, nên field vai trò không tới được controller. Nhưng em không dựa một mình vào pipe: service vẫn chép từng field từ DTO sang câu cập nhật, không gán nguyên object vào entity. Thêm một field vào DTO sau này là một quyết định có chủ ý, hiện rõ trong diff."

Lưu ý về whitelist: theo docs NestJS, nó loại các thuộc tính không có decorator validation nào. Một field có decorator — dù chỉ @IsOptional() — là "được phép". Vì vậy DTO dùng cho cập nhật hồ sơ không được tái sử dụng DTO của admin có field role; mỗi use case một DTO.

flowchart TD
  B["Body: name, phone, role"] --> VP{"ValidationPipe<br/>whitelist + forbid"}
  VP -- "role không có trong DTO" --> E["400"]
  VP -- "chỉ name, phone" --> SV["Service map từng field"]
  SV --> DB["UPDATE name, phone<br/>WHERE id = user từ phiên"]

4.4 Webhook: ký trên byte thô, timestamp, chống trùng, nhiều secret khi xoay

// ===== SAI (Laravel) =====
Route::post('/webhooks/partner/'.config('services.partner.path_secret'), function (Request $request) {
    $payload = $request->json()->all();                                       // tin vì "URL bí mật"
    $calc = hash_hmac('sha256', json_encode($payload['data']), config('services.partner.secret'));
    if ($payload['signature'] == $calc) { /* xử lý */ }
    // chữ ký nằm trong body, tính trên JSON serialize LẠI, so bằng ==, không timestamp, không chống trùng
});

// ===== ĐÚNG (Laravel 13) — middleware VerifyPartnerWebhook =====
public function handle(Request $request, Closure $next): Response
{
    $id   = (string) $request->header('Webhook-Id', '');
    $ts   = (string) $request->header('Webhook-Timestamp', '');
    $sig  = (string) $request->header('Webhook-Signature', '');
    $body = $request->getContent();                                            // byte thô, chưa parse

    if ($id === '' || strlen($id) > 255 || ! ctype_digit($ts)) {
        abort(401);
    }

    $signed = $id.'.'.$ts.'.'.$body;
    $ok = collect(config('services.partner.webhook_secrets'))                  // nhiều secret khi đang xoay
        ->contains(fn (string $secret) => hash_equals(hash_hmac('sha256', $signed, $secret), $sig));
    if (! $ok) {
        Log::warning('webhook.verification_failed', ['sender' => 'partner', 'id' => $id]);   // tín hiệu mục 6
        abort(401);
    }
    if (abs(time() - (int) $ts) > 300) {                                       // ±5 phút
        abort(401);
    }
    if (! Cache::add("webhook:partner:{$id}", 1, now()->addDay())) {            // SET NX: đã nhận rồi
        return response()->noContent();                                        // 204 để bên gửi ngừng gửi lại
    }
    return $next($request);
}
Cách nói khi phỏng vấn: "Bản sai tin vào một đường dẫn bí mật, đặt chữ ký trong chính body, tính chữ ký trên JSON đã serialize lại và so bằng phép so sánh thường. Bản đúng đọc ba header là mã sự kiện, thời điểm và chữ ký, tính HMAC trên mã sự kiện, thời điểm và đúng body thô, so bằng hàm thời gian hằng, thử lần lượt các secret còn hiệu lực để xoay key không gián đoạn. Qua chữ ký rồi mới kiểm lệch giờ năm phút và mã sự kiện chưa từng nhận. Sai ở bước nào cũng trả cùng một lỗi chung và ghi log để theo dõi."

Bốn chi tiết khi bị hỏi sâu:

  1. Đánh dấu "đã nhận" trước hay sau khi xử lý. Mẫu trên đánh dấu trước — nếu xử lý lỗi giữa chừng, lần gửi lại sẽ bị bỏ qua và mất sự kiện. Bản production nên ghi sự kiện vào bảng inbox có unique constraint trên id trong cùng transaction với việc xử lý, rồi trả 2xx; pattern đầy đủ ở bài Idempotency key với Redis và At-least-once / exactly-once.
  2. Trả 401 chung cho mọi lỗi kiểm. Lý do cụ thể đi vào log, không vào response — docs NestJS mục Webhooks mô tả đúng hành vi này ("The reason never goes to the sender").
  3. Kích thước body. HMAC phải đọc hết body; giới hạn kích thước ở Nginx/body parser trước khi vào middleware (docs NestJS: mặc định 100 kB với Express, 1 MiB với Fastify).
  4. Chữ ký có định dạng riêng của từng bên gửi (Stripe, GitHub, Standard Webhooks đều khác nhau về header và chuỗi được ký). Mẫu trên là một scheme minh hoạ; khi tích hợp thật, dùng thư viện chính thức của bên gửi nếu có.
// ===== ĐÚNG (NestJS 11) =====
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true });   // giữ byte thô cho việc kiểm chữ ký

// partner-webhook.guard.ts
@Injectable()
export class PartnerWebhookGuard implements CanActivate {
  constructor(private readonly redis: Redis, private readonly config: ConfigService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest<RawBodyRequest<Request>>();
    const id  = String(req.headers['webhook-id'] ?? '');
    const ts  = String(req.headers['webhook-timestamp'] ?? '');
    const sig = Buffer.from(String(req.headers['webhook-signature'] ?? ''), 'hex');
    if (!req.rawBody || !id || id.length > 255 || !/^\d+$/.test(ts)) throw new UnauthorizedException();

    const signed  = Buffer.concat([Buffer.from(`${id}.${ts}.`), req.rawBody]);
    const secrets = this.config.getOrThrow<string>('PARTNER_WEBHOOK_SECRETS').split(',');
    const ok = secrets.some((s) => {
      const expected = createHmac('sha256', s).update(signed).digest();
      return expected.length === sig.length && timingSafeEqual(expected, sig);  // khác độ dài thì timingSafeEqual ném lỗi
    });
    if (!ok) throw new UnauthorizedException();
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new UnauthorizedException();

    const first = await this.redis.set(`webhook:partner:${id}`, '1', 'EX', 86400, 'NX');
    (req as any).webhookDuplicate = first !== 'OK';                     // controller trả 2xx và bỏ qua nếu trùng
    return true;
  }
}
Cách nói khi phỏng vấn: "Ở NestJS điểm hay quên nhất là body đã bị parse thành JSON trước khi tới code của mình, nên em bật tuỳ chọn giữ body thô khi tạo ứng dụng và tính chữ ký trên đúng các byte đó. Phần còn lại giống bản Laravel: so thời gian hằng, kiểm độ dài trước vì hàm so sánh của Node ném lỗi khi hai bên khác độ dài, kiểm lệch giờ, rồi đặt khoá mã sự kiện trong Redis với điều kiện chưa tồn tại để chống nhận trùng."

Docs NestJS hiện có trang Webhooks mô tả một module nhận webhook dựng sẵn (scheme standard/stripe/github, dung sai mặc định '5m', chống trùng theo ID, nhiều secret khi xoay). Bài này mới đọc docs qua Context7, chưa cài thử package đó — ghi ở mục Chưa verify; mẫu guard ở trên dùng API ổn định (rawBody, node:crypto) để không phụ thuộc vào nó.

flowchart TD
  RQ["POST webhook"] --> SZ{"Body ≤ giới hạn?<br/>(Nginx / body parser)"}
  SZ -- "không" --> E413["413"]
  SZ -- "có" --> SG{"HMAC(id.ts.raw) khớp<br/>một secret còn hiệu lực?"}
  SG -- "không" --> E401["401 chung + log lý do"]
  SG -- "có" --> TS{"Lệch giờ ≤ 300 s?"}
  TS -- "không" --> E401
  TS -- "có" --> ID{"id chưa thấy?"}
  ID -- "đã thấy" --> OK2["2xx, bỏ qua"]
  ID -- "mới" --> H["Parse JSON + DTO → xử lý"]

4.5 Link tải và signed URL: chữ ký chứng minh "server phát link", không chứng minh "ai đang bấm"

// ===== SAI (Laravel) =====
Route::get('/reports/{uuid}/download', function (string $uuid) {
    return Storage::download(Report::where('uuid', $uuid)->firstOrFail()->path);   // "UUID khó đoán" = bảo mật
});

// ===== ĐÚNG (Laravel 13) =====
// Khi báo cáo sẵn sàng: gửi email chứa link ký, hạn 30 phút
$url = URL::temporarySignedRoute('reports.download', now()->plus(minutes: 30), ['report' => $report->id]);

Route::get('/reports/{report}/download', function (Report $report) {
    Gate::authorize('download', $report);          // vẫn kiểm quyền: link có thể bị chuyển tiếp
    return Storage::download($report->path);
})->name('reports.download')->middleware(['auth', 'signed']);
Cách nói khi phỏng vấn: "Một mã định danh khó đoán không phải chữ ký: nó không có hạn, và nằm trong log hay lịch sử trình duyệt là ai có nó cũng dùng được mãi. Bản đúng tạo link có chữ ký kèm thời hạn ba mươi phút, route có middleware kiểm chữ ký. Nhưng chữ ký chỉ chứng minh link do server phát ra và chưa bị sửa, không chứng minh người đang bấm là người nhận, nên em vẫn yêu cầu đăng nhập và kiểm quyền với báo cáo đó."

Bốn điều cần biết về signed URL của Laravel (docs 13.x + source UrlGenerator):

  1. Cơ chế: tham số signature là HMAC-SHA256 của URL bằng key ứng dụng, kiểm bằng hash_equals, thử lần lượt mọi key (key hiện tại và các key cũ khi xoay); expires nằm trong phần được ký nên không sửa được hạn.
  2. Mặc định ký URL tuyệt đối, gồm cả domain. Với hệ thống nhiều trường trên nhiều subdomain, link ký cho trường A không dùng được ở trường B — một ràng buộc có lợi. signed:relative bỏ domain khỏi chữ ký; chỉ dùng khi có lý do (proxy đổi host) và biết mình mất ràng buộc đó.
  3. hasValidSignatureWhileIgnoring(['page', 'order']) cho client đổi một số tham số mà chữ ký vẫn đúng. Tham số bị bỏ qua là input không được bảo vệ — chỉ dùng cho tham số trình bày (trang, sắp xếp), không bao giờ cho tham số chọn dữ liệu hay quyền.
  4. Không thu hồi được từng link. Muốn dùng một lần hoặc thu hồi, phải thêm trạng thái (bảng token, cột downloaded_at) — đánh đổi giống JWT ở Bài 7.
// ===== ĐÚNG (NestJS) — link tải ký bằng HMAC, gắn người nhận và hạn =====
private payload(reportId: number, userId: number, exp: number): string {
  return `GET\n/reports/${reportId}/download\n${userId}\n${exp}`;     // ký cả method, path, người nhận, hạn
}

sign(reportId: number, userId: number, ttlSec = 1800): string {
  const exp = Math.floor(Date.now() / 1000) + ttlSec;
  const sig = createHmac('sha256', this.key).update(this.payload(reportId, userId, exp)).digest('base64url');
  return `/reports/${reportId}/download?exp=${exp}&sig=${sig}`;
}

verify(reportId: number, userId: number, exp: string, sig: string): boolean {   // userId lấy từ phiên đăng nhập
  if (!/^\d+$/.test(exp) || Number(exp) < Date.now() / 1000) return false;
  const expected = createHmac('sha256', this.key).update(this.payload(reportId, userId, Number(exp))).digest();
  const given = Buffer.from(sig, 'base64url');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
Cách nói khi phỏng vấn: "NestJS không có sẵn link ký như Laravel nên em tự ký bằng HMAC, và em đưa vào chuỗi được ký cả phương thức, đường dẫn, mã người nhận và thời hạn. Lúc kiểm, mã người nhận lấy từ phiên đăng nhập hiện tại chứ không từ URL, nên link bị chuyển tiếp cho người khác thì chữ ký tính ra sẽ khác và bị từ chối. Thay vì HMAC tự viết, dùng một JWT sống ngắn có audience riêng cho việc tải báo cáo cũng được, miễn là kiểm đủ hạn, audience và người nhận."
sequenceDiagram
  participant S as Server
  participant M as Email người nhận
  participant U as Trình duyệt
  S->>M: Link: path + exp + sig(path, người nhận, exp)
  M->>U: Người nhận bấm link
  U->>S: GET link + cookie phiên
  S->>S: sig khớp? exp còn? người nhận = user trong phiên?
  S->>S: Policy: user được tải báo cáo này?
  S-->>U: 200 file / 403

4.6 Cookie: để framework mã hoá có xác thực, quyết định lấy từ server, phạm vi host-only

// ===== SAI (Laravel) =====
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->encryptCookies(except: ['role', 'is_premium']);   // để JS đọc → client sửa được
})
// middleware tự viết
if ($request->cookie('role') === 'admin') { return $next($request); }   // CWE-784

// ===== ĐÚNG (Laravel 13) =====
// Không thêm cookie nào mang quyết định vào except; quyết định lấy từ user trong phiên
if ($request->user()?->can('access-admin')) { return $next($request); }
// .env
SESSION_DOMAIN=null            # cookie phiên host-only, không gửi sang subdomain trỏ ra bên thứ ba
SESSION_SECURE_COOKIE=true
Cách nói khi phỏng vấn: "Laravel mặc định mã hoá có xác thực mọi cookie, và từ bản vá năm 2018 thì không serialize cookie nữa. Lỗi thường gặp là tự đưa một cookie vào danh sách ngoại lệ để JavaScript đọc được, rồi lại dùng chính cookie đó để quyết định quyền. Em để quyết định nằm ở người dùng trong phiên và chính sách phân quyền; cookie phiên thì host-only, không đặt domain chung cho mọi subdomain."

Phía NestJS: nếu cần cookie mang dữ liệu, dùng session phía server (Redis) và cookie chỉ chứa session ID, hoặc cookie có chữ ký (signed cookie của cookie-parser với secret) — cookie ký chống sửa nhưng không giấu nội dung và không có hạn trong chữ ký, nên không dùng cho dữ liệu nhạy cảm hay quyết định cần thu hồi. Cách cấu hình cookie phiên chi tiết đã có ở Bài 7 mục 4.4.

4.7 Artifact, công cụ CI, plugin, script front-end: ghim theo nội dung và kiểm trước khi chạy

# ===== SAI (.gitlab-ci.yml) =====
test:
  script:
    - curl -s https://tool.example.com/install.sh | bash          # nội dung URL đổi được, chạy ngay
deploy_production:
  script:
    - docker pull registry.example.com/lms/app:latest             # tag ghi đè được

# ===== ĐÚNG (.gitlab-ci.yml) =====
variables:
  TOOL_VERSION: "1.4.2"
  TOOL_SHA256: "<sha256 lấy từ trang release của vendor, commit vào repo, review như code>"

test:
  script:
    - curl -fsSLo tool.tar.gz "https://tool.example.com/releases/${TOOL_VERSION}/tool.tar.gz"
    - echo "${TOOL_SHA256}  tool.tar.gz" | sha256sum -c -         # lệch 1 byte → job đỏ, không chạy
    - tar xzf tool.tar.gz && ./tool --version

build:
  rules:
    - if: $CI_COMMIT_TAG                                          # chỉ tag release (tag được bảo vệ)
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
    - docker inspect --format '{{index .RepoDigests 0}}' "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" > image.digest
  artifacts:
    paths: [image.digest]

deploy_production:
  needs: [build]
  environment: production
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  script:
    - IMAGE="$(cat image.digest)"                                 # registry/lms/app@sha256:...
    - cosign verify --key cosign.pub "$IMAGE"                     # nếu có hạ tầng ký image
    - ./deploy.sh "$IMAGE"                                        # blue-green kéo theo digest, không theo tag
Cách nói khi phỏng vấn: "Bản sai có hai điểm nhận không kiểm: tải script cài đặt rồi chạy ngay, và deploy theo tag mới nhất mà ai push được là đổi được. Bản đúng ghim phiên bản công cụ và mã băm của nó trong repo, kiểm mã băm trước khi giải nén, lệch một byte là job đỏ. Image chỉ build từ tag release được bảo vệ, ghi lại mã băm nội dung ngay sau khi đẩy lên, và cả staging lẫn production kéo đúng mã băm đó; có hạ tầng ký thì kiểm chữ ký trước khi chạy."

Ba điểm cần nói rõ:

  1. Hash phải đến từ kênh khác file. TOOL_SHA256 được commit vào repo và đi qua review; nếu job tải hash từ cùng server với file thì vô nghĩa (mục 2).
  2. Deploy theo digest là thứ biến "build một lần" thành bảo đảm. Build một lần mà deploy theo tag thì giữa staging và production, tag vẫn có thể bị push đè. Digest là hash nội dung — đổi một byte là đổi digest.
  3. Ký image (cosign) là bước nâng cấp, không phải điều kiện đầu. Nó chứng minh image do pipeline có key ký, độc lập với registry. Cần quản lý key (hoặc keyless qua OIDC) và chính sách kiểm ở nơi deploy. Team nhỏ trên VPS: digest + registry có quyền push tối thiểu đã chặn phần lớn rủi ro; ký là bước tiếp theo khi phân phối image cho bên khác. Lệnh cosign ở trên chưa chạy thật trong bài này.
// ===== Moodle — config.php (LMS nhiều app node) =====
$CFG->disableupdateautodeploy = true;   // tắt cài/cập nhật plugin từ giao diện admin trên server
// Plugin vào hệ thống qua repo: thêm vào cây code, review, build, deploy như code của team.
Cách nói khi phỏng vấn: "Moodle cho admin cài plugin từ giao diện web, server tự tải gói về và ghi vào thư mục code. Nó có so mã băm với giá trị lấy từ thư mục plugin, nhưng mã băm và gói đến từ cùng một nguồn. Với hệ thống nhiều node chạy từ bản build, em tắt hẳn tính năng đó bằng cờ cấu hình chính thức, và plugin đi qua repo, review, build như mọi code khác — vừa giữ toàn vẹn vừa tránh các node lệch nhau."
<!-- Front-end: script bên thứ ba bắt buộc lấy từ CDN (ưu tiên tự host qua bundler — Bài 3 mục 4.9) -->
<script src="https://cdn.example.com/lib@3.2.1/lib.min.js"
        integrity="sha384-<hash của đúng file 3.2.1>"
        crossorigin="anonymous"></script>

# Header (MDN): bắt đầu ở chế độ chỉ báo cáo, hết vi phạm mới chuyển sang chặn
Integrity-Policy-Report-Only: blocked-destinations=(script), endpoints=(integrity)
Reporting-Endpoints: integrity="https://lms.example.com/csp-reports"
Cách nói khi phỏng vấn: "Nếu buộc phải nạp script từ CDN, em ghim mã băm của đúng phiên bản đó, trình duyệt tự tính lại và từ chối chạy nếu lệch. Thuộc tính cross-origin là bắt buộc, thiếu là request luôn hỏng. Muốn chắc không ai quên ghim, có thêm header chính sách toàn vẹn: chạy chế độ chỉ báo cáo trước, khi không còn vi phạm mới bật chặn. Nhưng lựa chọn đầu tiên của em vẫn là tự host qua bundler."
flowchart TD
  T["Cần một file/code từ bên ngoài"] --> K{"Loại gì?"}
  K -- "Công cụ trong CI" --> K1["Ghim version + SHA-256 trong repo<br/>sha256sum -c trước khi chạy"]
  K -- "Image ứng dụng" --> K2["Build 1 lần, deploy theo digest<br/>(+ cosign verify nếu có)"]
  K -- "Plugin Moodle" --> K3["disableupdateautodeploy<br/>plugin qua repo + build"]
  K -- "Script front-end" --> K4["Tự host; nếu CDN: SRI +<br/>crossorigin + Integrity-Policy"]

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

5.1 Bảng tổng hợp

Kiểm soátChặn gìKhông chặn gìChi phí, bối cảnh hợp
Định dạng chỉ-dữ-liệu (JSON) thay native serializeDữ liệu điều khiển việc dựng object, gadget chainSửa nội dung, mass assignmentRẻ; mọi chỗ dữ liệu đến từ ngoài vùng tin cậy
allowed_classes / serializable_classesThu hẹp class được dựng từ cache/queue nội bộKhông đủ cho input người dùng (PHP manual)Rẻ; phải liệt kê class, lỗi khi quên
MAC (HMAC) + so constant-timeSửa dữ liệu giữa các bên chung secretBên giữ secret tự giả; phát lại nếu không có hạnRẻ; webhook 1–1, signed URL, cookie, job
Chữ ký số (public key ghim sẵn)Giả mạo khi người kiểm không được phép kýNguồn ký bị chiếm (SolarWinds)Quản lý key; bản cập nhật, image, token cho nhiều service
Timestamp + ID dùng một lầnPhát lại dữ liệu đúng chữ kýSửa dữ liệu (cần MAC đi kèm)Cần NTP và kho ID ngắn hạn
Allowlist field (DTO, #[Fillable], validated())Mass assignmentThiếu kiểm quyền (A01)Rẻ; thêm field mới phải sửa allowlist
#[Locked] (Livewire)Client sửa property không được phép đổiThay cho policy kiểm quyềnRẻ; phải nhớ khoá từng property
SRI + Integrity-PolicyCDN trả nội dung khác hash đã ghimNội dung động; nguồn bị chiếm từ trước khi ghimCập nhật hash mỗi lần nâng; tự host thường rẻ hơn
Checksum công cụ CI (hash trong repo)File bị sửa ở nơi tải (Codecov)Vendor bị chiếm trước khi phát hành hashRẻ; phải cập nhật hash khi nâng phiên bản
Deploy theo digest + ký imageTag bị push đè, image lạ trong registryBuild system bị chiếm (A03, SLSA)Digest gần như miễn phí; ký cần hạ tầng key
Redis ACL + mạng riêng (+ TLS nếu qua mạng không tin cậy)Ghi lén vào queue/cache/sessionApp bị chiếm (có sẵn credential Redis)Thấp; bắt buộc khi worker ở nhiều VPS
Code read-only + tắt cài plugin từ webRuntime tự sửa code, node lệch nhauCode độc đã có trong bản buildCần quy trình đưa plugin qua repo
Mỗi môi trường một key + kế hoạch xoay keyLộ key ở staging lan sang productionLộ key productionCần hỗ trợ nhiều key khi chuyển tiếp (Laravel: key cũ)

5.2 Hash, MAC hay chữ ký số — chọn theo số bên và hướng tin cậy

1. Câu hỏi quyết định: "ai được tạo dữ liệu hợp lệ, ai chỉ được kiểm?" Nếu hai tập đó trùng nhau (một server tự ký link rồi tự kiểm, hai hệ thống thoả thuận một secret webhook), MAC đủ và rẻ. Nếu một bên phát và nhiều bên kiểm, MAC biến mọi bên kiểm thành bên có thể giả.

2. Ví dụ minh hoạ có số: token nội bộ đi giữa 5 thành phần — service phát token, student portal, Moodle plugin, 2 nhóm worker. Dùng HMAC: 5 nơi giữ cùng secret; xác suất "ít nhất một nơi lộ secret" xấp xỉ cộng dồn rủi ro của 5 nơi, và nơi nào lộ cũng giả được token cho cả 4 nơi còn lại. Dùng chữ ký số: chỉ 1 nơi giữ private key; 4 nơi kia giữ public key — lộ public key không giả được gì. Đổi lại: phải phát hành và xoay cặp key, và kiểm chữ ký chậm hơn HMAC (vẫn ở mức micro giây mỗi lần — ước lượng, chưa đo trong bài này).

3. Hash trần chỉ hợp khi kênh đưa hash tới độc lập và đáng tin hơn kênh tải file (hash commit trong repo đã review; hash in trên trang release ký bởi vendor). Không có kênh như vậy thì hash chỉ bắt lỗi truyền, không bắt giả mạo.

Cách nói khi phỏng vấn: "Em chọn theo câu hỏi ai được tạo dữ liệu hợp lệ và ai chỉ được kiểm. Nếu là cùng một bên, hoặc hai bên thoả thuận với nhau, thì HMAC đủ và rẻ. Nếu một bên phát mà nhiều bên kiểm, như token đi qua năm thành phần, thì HMAC biến mọi bên kiểm thành bên có thể giả, nên em dùng chữ ký số: chỉ một nơi giữ khoá bí mật, các nơi khác giữ khoá công khai. Còn mã băm trần chỉ có giá trị khi nó đến qua một kênh khác và đáng tin hơn kênh tải file."

5.3 Mã hoá hay chỉ ký

Chữ ký/MAC không giấu nội dung. Tham số của signed URL nằm trơn trong URL; payload JWT chỉ là base64 — ai cầm token đều đọc được. Laravel Crypt::encrypt, cookie mã hoá và ShouldBeEncrypted là mã hoá có xác thực: vừa giấu vừa chống sửa. Chọn theo câu hỏi "nội dung có được phép lộ cho người cầm nó không": mã báo cáo, thời hạn — ký là đủ; số căn cước, điểm chưa công bố — mã hoá, hoặc tốt hơn là không đưa ra ngoài, chỉ đưa ID.

5.4 Native serialization trong hệ thống nội bộ — hai trường phái

1. Trường phái "không bao giờ" (cheat sheet Deserialization, PHP manual): native format là rủi ro không cần thiết; dùng JSON ở mọi nơi, kể cả cache và queue nội bộ. Ưu: một quy tắc đơn giản, review dễ, payload tương thích ngược tốt hơn qua các lần deploy, đọc được bằng mắt khi debug. Nhược: mất tiện lợi của việc cất nguyên object (model, value object) — phải viết toArray/fromArray.

2. Trường phái "được, nếu kho được bảo vệ" (cách queue Laravel vẫn làm trong 13.x): native serialize chấp nhận được khi chỉ thành phần tin cậy ghi được vào kho (ACL, mạng riêng) và, với dữ liệu quan trọng, có lớp toàn vẹn (ShouldBeEncrypted). Ưu: tiện, framework lo hết (ví dụ model Eloquent trong job chỉ được lưu dạng định danh và tra lại khi chạy — docs Queues). Nhược: an toàn phụ thuộc vào cấu hình hạ tầng mà team ứng dụng không trực tiếp nhìn thấy.

3. Chọn theo bối cảnh: chính Laravel 13 đã nghiêng về trường phái 1 cho cache (mặc định serializable_classes => false) nhưng giữ trường phái 2 cho queue. Hệ thống mà Redis được nhiều hệ thống dùng chung, worker nằm trên nhiều VPS, hoặc có công cụ bên ngoài ghi vào queue → nghiêng về trường phái 1 (job mang ID, payload JSON hoặc mã hoá). Một app, một Redis riêng trong mạng nội bộ khoá chặt → trường phái 2 chấp nhận được.

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

  1. Tìm mọi unserialize / thư viện kiểu node-serialize / YAML nạp không an toàn trên dữ liệu có thể đến từ ngoài — hậu quả nặng nhất (chạy code), sửa rẻ nhất (đổi sang JSON).
  2. Kiểm phiên bản framework có CVE thuộc nhóm này (ví dụ Livewire 3 ≤ 3.6.3) — nâng cấp là cách duy nhất khi advisory ghi "no workarounds".
  3. Rà mass assignment: $guarded = [], ->all() đổ vào model, Object.assign/save(body), ValidationPipe thiếu whitelist, public property Livewire chứa ID không khoá.
  4. Webhook: chữ ký trên body thô, constant-time, timestamp, chống trùng.
  5. Redis: ACL, mạng riêng; Laravel 13 thêm serializable_classes; job nhạy cảm bật ShouldBeEncrypted, job mang ID.
  6. CI/CD: bỏ "tải rồi chạy" không hash; deploy theo digest; tag/nhánh được bảo vệ.
  7. Key: mỗi môi trường một APP_KEY; viết sẵn quy trình xoay key (key cũ giữ trong danh sách key trước để giải dữ liệu cũ trong thời gian chuyển tiếp).
  8. Front-end: tự host script bên thứ ba; còn lại thêm SRI.

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

6.1 Code review: mẫu cần soi

Mẫu trong diffCâu hỏi reviewer
unserialize(, node-serialize, v8.deserialize, YAML load với schema mở rộngChuỗi này có lúc nào đi qua client, file upload, Redis dùng chung không? Sao không dùng JSON?
$guarded = [], Model::unguard() ngoài seeder, ->fill($request->all()), ->update($request->all())Model có cột nào nhạy cảm? Lấy dữ liệu từ validated() chưa?
Object.assign(entity, body), repo.save(body), @Body() body: anyDTO đâu? Pipe có whitelist không? Map từng field chưa?
Public property Livewire chứa ID/giá/điểm không có #[Locked]Client đổi giá trị này thì chuyện gì xảy ra? Có kiểm quyền trong action không?
Thêm tên vào encryptCookies(except: …)Cookie này có được dùng để quyết định gì ở server không?
Route webhook không có middleware kiểm chữ ký; hash_hmac(...) ==; JSON.stringify(req.body) để tính HMACKý trên byte nào? So sánh constant-time? Có timestamp và chống trùng?
hasValidSignatureWhileIgnoring([...]), signed:relativeTham số bị bỏ qua có chọn dữ liệu không? Vì sao bỏ domain khỏi chữ ký?
curl … | bash, wget rồi chạy, :latest trong file deployHash ở đâu, ai review hash? Deploy theo digest chưa?
<script src="https://…"> thiếu integrityTự host được không? Nếu không, hash và crossorigin đâu?
Cách nói khi phỏng vấn: "Khi review em có một danh sách mẫu rất ngắn cho A08: có giải serialize gốc không, có đổ nguyên request vào model hay entity không, có property phía client nào chứa mã bản ghi mà không khoá không, có cookie nào bị đưa ra khỏi danh sách mã hoá không, webhook ký trên byte nào và so sánh thế nào, và trong pipeline có chỗ nào tải về rồi chạy ngay hoặc deploy theo tag mới nhất không. Mỗi mẫu đi kèm một câu hỏi cụ thể để tác giả trả lời, thay vì một nhận xét chung chung."

6.2 Công cụ tự động phủ được gì

  1. SAST: bắt tốt các lời gọi có tên rõ ràng (unserialize với tham số từ request, package node-serialize, $guarded = []) — bộ rule có sẵn của các công cụ như Semgrep nhắm vào những mẫu này (theo hiểu biết, chưa chạy trong bài). Bắt kém: luồng dữ liệu qua Redis/queue (công cụ không biết payload trong Redis đến từ đâu), thiếu #[Locked], thứ tự kiểm chữ ký.
  2. SCA (composer audit, npm audit — chi tiết ở Bài 3): bắt CVE của framework thuộc nhóm này như Livewire CVE-2025-54068, Laravel CVE-2018-15133.
  3. DAST: phát hiện được một số dấu hiệu (giá trị trông như serialize/base64 trong cookie, field thừa được chấp nhận) nhưng không chứng minh được toàn vẹn của pipeline hay queue.
  4. Kiểm tra trong CI: script grep chặn merge khi thêm | bash hay :latest vào file deploy; test tự động gửi webhook sai chữ ký/quá hạn/trùng ID và kỳ vọng 401/401/2xx-bỏ-qua; test gửi field thừa vào API cập nhật hồ sơ và kỳ vọng 400 hoặc field không đổi.

6.3 Tín hiệu trong production

Tín hiệuNền bình thườngBất thường nghĩa là gì
Số webhook bị từ chối theo bên gửi + lý do (chữ ký, quá hạn, trùng)Gần 0 với lỗi chữ ký; vài lần trùng do bên gửi retryTăng vọt ngay sau khi đổi secret → cấu hình sai; rải rác đều đặn → có người dò endpoint
Số 403 InvalidSignatureException trên route kýThấp (link hết hạn)Tăng sau khi đổi APP_KEY mà không giữ key cũ → link trong email cũ hỏng hàng loạt
Số lỗi giải mã "MAC is invalid" (cookie, job)~0Có người sửa cookie/payload; hoặc các node chạy APP_KEY khác nhau
Lỗi checksum snapshot Livewire~0Có người sửa snapshot; source Checksum.php còn giới hạn số lần sai bằng RateLimiter
Số lần gặp class không được phép khi đọc cache (callback handleUnserializableClassUsing)0 sau khi chuyển xongPayload cũ còn sót, hoặc ai đó ghi object vào cache
Digest image đang chạy trên từng node so với digest của bản releaseTất cả khớpCó node chạy image khác → deploy lỗi hoặc có người can thiệp tay

Ví dụ minh hoạ có số — key lệch giữa các node: LB chia đều cho 5 app node; một node được dựng lại bằng file .env cũ có APP_KEY khác. Khoảng 1/5 = 20% request rơi vào node đó: cookie phiên do 4 node kia mã hoá không giải được ở node này → người dùng bị "đăng xuất ngẫu nhiên" 20% số lần bấm, và lỗi "MAC is invalid" tăng đúng từ một host. Tín hiệu "lỗi MAC theo host" bắt được sự cố trong vài phút; không có nó, team sẽ đi tìm lỗi ở Redis session. Bài học vận hành: APP_KEY thuộc cấu hình phát hành, phải giống nhau trên mọi node của một môi trường và khác nhau giữa các môi trường.

Cách nói khi phỏng vấn: "Trong production em đo số lần kiểm toàn vẹn thất bại, tách theo bên gửi, theo lý do và theo máy chủ: webhook bị từ chối, link ký không hợp lệ, lỗi giải mã cookie hay job. Tăng vọt ngay sau khi đổi khoá là lỗi cấu hình; rải rác đều đặn là có người dò; tập trung ở một máy chủ là máy đó chạy khoá khác. Ví dụ một trên năm node dùng nhầm khoá thì khoảng hai mươi phần trăm thao tác người dùng bị đăng xuất, và biểu đồ lỗi theo máy chủ chỉ ra ngay node đó."

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

Dữ kiện từ cv-v3.md; chỗ có dấu * là giả định, CV không nêu. Giá trị Moodle đọc từ source nhánh MOODLE_405_STABLE; CV không nói LMS Uni đang chạy bản Moodle nào*, nên phải kiểm lại trên bản thật.

7.1 LMS Uni — Moodle trên 5 app node, MySQL primary/replica, NFS moodledata, Redis tách session/cache/lock, worker nhiều VPS, student portal NestJS

  1. Đường cài và nâng plugin Moodle. CV có Nginx LB trước 5 app node và quy trình blue-green + build một lần. Nếu admin cài plugin từ giao diện web, code mới chỉ được ghi vào thư mục code của node đang xử lý request đó (NFS trong CV dành cho moodledata, không phải code*), web server phải có quyền ghi vào thư mục code, và bản build blue-green kế tiếp không có plugin đó. Đề xuất: $CFG->disableupdateautodeploy = true;, plugin (kể cả plugin tự phát triển) đi qua repo → review → build → deploy như code. Phép so MD5 của Moodle khi tự tải ZIP chỉ là kiểm cùng kênh (mục 2).
  2. Payload job và cache "tương thích ngược" (CV có nêu). CV không nói queue dùng công nghệ gì (adhoc task của Moodle hay queue của portal)*. Dù là gì, payload JSON có trường version là quyết định phục vụ cả hai mục tiêu: bản cũ/mới đọc được của nhau trong lúc blue-green, và không có bước dựng object theo dữ liệu trong Redis. Job "xuất báo cáo", "chấm điểm" nên mang ID và tra lại điều kiện từ DB (mục 4.2).
  3. Redis tách instance và worker trên nhiều VPS (CV có nêu). Nghĩa là Redis được truy cập qua mạng từ nhiều máy. Instance session là gốc tin cậy của đăng nhập: ai ghi được vào đó là tạo được phiên hợp lệ (dữ liệu phiên PHP nằm trong Redis dạng serialize của session handler*). Cần: mỗi instance một ACL user với quyền tối thiểu, chỉ nghe trên mạng riêng/firewall theo IP worker*, TLS nếu đi qua mạng không tin cậy*.
  4. Student portal NestJS dùng chung database với Moodle (CV có nêu). Toàn vẹn dữ liệu theo nghĩa nghiệp vụ: nếu portal ghi thẳng vào bảng của Moodle*, nó bỏ qua validation, event và cache invalidation của Moodle — dữ liệu "đúng cấu trúc" nhưng Moodle không coi là hợp lệ (gần với CWE-345 ở tầng ứng dụng). Hướng an toàn: portal chỉ đọc bảng Moodle, ghi qua web service của Moodle hoặc vào bảng riêng của portal*.
  5. Link tải báo cáo xác thực bằng JWT (CV có nêu). Đây chính là "dữ liệu ký đi qua ranh giới" của mục 4.5: hạn ngắn, audience riêng report-download, gắn người nhận, ghim thuật toán (A04), và vẫn kiểm quyền khi tải. CV không nói hạn và audience*.
  6. Build một lần, promote bằng tag (CV có nêu). Thêm deploy theo digest và tag được bảo vệ để biến "build một lần" thành bảo đảm toàn vẹn (mục 3.8, 4.7).

7.2 LMS Micro-learning — Laravel + Livewire, NestJS, Next.js, PostgreSQL, Redis, MinIO, GitLab CI, Docker, 4 trường

  1. Livewire. CV không nói phiên bản*. Nếu là Livewire 3 thì phải ≥ 3.6.4 vì CVE-2025-54068 (NVD: chạy code từ xa không cần đăng nhập ở một số cấu hình component, không có workaround). Ngoài vá: ID trong public property dùng #[Locked] hoặc property kiểu model, action luôn Gate::authorize.
  2. Phiên bản Laravel và cache. CV không nói phiên bản*. Nếu đã lên 13 bằng cách nâng cấp, kiểm config/cache.php có serializable_classes; CV có chiến lược cache Redis hit ratio 93% — nếu cache đang chứa object (DTO dashboard)*, chuyển sang mảng hoặc liệt kê class.
  3. GitLab CI build image một lần cho 3 môi trường (CV có nêu). Thêm: digest ghi lại sau build, staging/production kéo theo digest, tag release được bảo vệ, job từ merge request không có secret deploy.
  4. 4 trường trên cùng codebase (CV có nêu). Nếu mỗi trường một subdomain*: signed URL mặc định ký cả domain nên link của trường A không dùng ở trường B — giữ nguyên, đừng chuyển sang relative; cookie phiên host-only. Mỗi môi trường một APP_KEY.
  5. MinIO (CV có trong stack). Presigned URL của object storage là cùng mô hình "dữ liệu ký qua ranh giới": hạn ngắn, cấp sau khi kiểm quyền, không log URL đầy đủ*. Chi tiết chữ ký của MinIO chưa tìm hiểu trong bài này.
  6. Idempotency key (CV có nêu). Cùng cơ chế với "ID dùng một lần" chống phát lại webhook — có thể nói như một bằng chứng team đã có tư duy chống trùng.

7.3 Trả lời mẫu

Cách nói khi phỏng vấn: "Ở LMS Uni em nhìn A08 theo các chỗ dữ liệu và code đi vòng ra ngoài rồi quay về. Code thì plugin Moodle không được cài từ giao diện web mà đi qua repo và bản build, vì có năm node và blue-green, cài tay trên một node là vừa lệch vừa không ai review. Dữ liệu thì job và cache dùng payload có version để tương thích ngược khi blue-green, và chỉ mang mã bản ghi; Redis có nhiều máy truy cập nên khoá bằng ACL và mạng riêng, vì instance session là gốc tin cậy của đăng nhập. Link tải báo cáo là token ký có hạn ngắn, đúng đối tượng nhận, và vẫn kiểm quyền khi tải. Ở LMS Micro-learning em thêm việc giữ Livewire trên bản đã vá và khoá các property chứa mã bản ghi."

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

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

Sự cốĐiều gì sai (giả định nào)Kiểm soát nào giúp
Codecov Bash Uploader (31/01 – 01/04/2021, Codecov security update)Lỗi trong quy trình tạo Docker image của Codecov để lộ credential sửa được script; script bị sửa định kỳ để gửi biến môi trường CI của khách hàng ra ngoài. Phía khách hàng: CI tải script và chạy ngay.Một khách hàng so shasum (đăng trên GitHub) với file tải về, thấy lệch và báo — Codecov ghi: khách hàng đã so checksum "may not be impacted". Bí mật tối thiểu trong job CI; xoay credential sau sự cố.
Laravel CVE-2018-15133 (NVD; CISA KEV thêm 16/01/2024)unserialize sau khi giải mã header X-XSRF-TOKEN — an toàn chỉ khi APP_KEY không lộBỏ serialize cookie (5.6.30); mỗi môi trường một key; không đặt bước dựng object sau bước kiểm MAC
Livewire CVE-2025-54068 (NVD, GHSA-29cq-5w36-x7w3, 07/2025)Livewire 3 ≤ 3.6.3: cách hydrate cập nhật property từ client cho phép chạy code từ xa không cần đăng nhập ở một số cấu hình componentSCA cảnh báo + nâng lên 3.6.4 nhanh (advisory: không có workaround); coi state phía client là input
GitHub mass assignment (03/2012, dẫn trong cheat sheet Mass Assignment)Framework cho gán mọi thuộc tính theo mặc định; người dùng gắn public key vào tổ chức bất kỳAllowlist (strong parameters), DTO
node-serialize CVE-2017-5941 (NVD)Thư viện "serialize" khôi phục được cả hàm từ chuỗiChỉ dùng JSON cho dữ liệu ngoài
Kịch bản #4 OWASP: state người dùng serialize Java đi qua clientĐẩy object graph xuống client cho "stateless"State ở server, client giữ ID; nếu bắt buộc thì JSON + MAC
SolarWinds (2020 — chi tiết ở Bài 3)Bản cập nhật mang chữ ký hợp lệ của vendor nhưng chứa mã độcNhắc rằng chữ ký chứng minh nguồn gốc, không chứng minh an toàn: rollout từng phần, giám sát hành vi sau cập nhật

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

  1. "Dữ liệu này do chính mình tạo ra" — câu biện minh cho mọi unserialize và mọi cookie không ký. Câu đúng phải là "dữ liệu này chưa từng rời vùng mình kiểm soát, và đây là bằng chứng".
  2. Kiểm chữ ký trên dữ liệu đã parse rồi serialize lại — khác byte là sai chữ ký, và cách "sửa" phổ biến là tắt kiểm.
  3. Chữ ký nằm trong chính body được ký — phải tách ra, và phải định nghĩa rõ chuỗi nào được ký.
  4. Có chữ ký nhưng không có hạn — phát lại vô thời hạn.
  5. Dùng chung một secret HMAC cho nhiều bên kiểm — bên nào lộ cũng giả được cho mọi bên.
  6. Đưa cookie vào danh sách không mã hoá "để JS đọc" rồi dùng chính cookie đó để quyết định ở server.
  7. $guarded = [] "tạm thời" — không bao giờ được bật lại.
  8. whitelist: true nhưng dùng chung DTO admin cho API người dùng — field có decorator vẫn "được phép".
  9. Cùng APP_KEY cho staging và production — một lỗi cấu hình ở staging thành sự cố ở production.
  10. Hash tải từ cùng server với file — cảm giác an toàn, không có bảo vệ thật trước giả mạo.
  11. Cài plugin/cập nhật từ giao diện admin trên một node của cụm — node lệch nhau, code chạy khác code đã review.

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

Câu hỏiTrả lời ngắn
A08 khác A03 thế nào?A03: mình tin ai trong chuỗi cung ứng. A08: ở điểm nhận mình có kiểm chứng niềm tin đó không. Codecov là A03 phía vendor, A08 phía khách hàng không so checksum.
Vì sao insecure deserialization có thể dẫn tới chạy code?Định dạng native cho dữ liệu chọn class và thuộc tính của object được dựng; magic method của các class có sẵn chạy với thuộc tính đó, nối thành chuỗi hành động. JSON không chọn được class.
allowed_classes có đủ không?Không cho input người dùng (PHP manual cấm bất kể option). Hợp làm lớp thứ hai cho cache/queue nội bộ, như serializable_classes của Laravel 13.
Đã mã hoá/ký bằng APP_KEY thì unserialize được không?Không nên: key lộ là mọi thứ sau MAC mở (CVE-2018-15133). Laravel bỏ serialize cookie và tắt giải object từ cache mặc định vì đúng lý do này.
Hash, MAC, chữ ký số khác nhau thế nào khi chọn?Hash: chỉ mạnh bằng kênh đưa hash. MAC: các bên chung secret, ai giữ cũng ký được. Chữ ký số: một bên ký, nhiều bên kiểm bằng public key.
Chống phát lại webhook thế nào?Ký cả timestamp và ID; dung sai vài phút; lưu ID đã xử lý ít nhất bằng dung sai; xử lý idempotent.
Mass assignment chặn ở đâu?Request (validated/DTO whitelist), model (fillable), field nhạy cảm gán tường minh, rồi policy kiểm quyền. Livewire: #[Locked] + authorize.
Signed URL của Laravel có đủ thay đăng nhập không?Chỉ khi link vốn dành cho "ai cầm cũng được" (huỷ đăng ký email). Dữ liệu cá nhân: thêm auth + policy, vì link có thể bị chuyển tiếp.
SRI có giải quyết polyfill.io không?Không: nội dung trả về thay đổi theo trình duyệt nên không ghim được hash. Lối ra là tự host.
"Build một lần" đã đủ toàn vẹn chưa?Chưa nếu deploy theo tag ghi đè được. Ghi digest, deploy theo digest, tag/nhánh bảo vệ; ký image khi phân phối rộng.

10. Chưa verify và Nguồn

Chưa verify

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

  1. Worker Laravel unserialize job thường không giới hạn class, và job ShouldBeEncrypted bị sửa thì dừng ở bước giải mã — đọc source CallQueuedHandler 13.x + docs Queues, chưa chạy với payload bị sửa.
  2. App nâng lên Laravel 13 mà thiếu khoá serializable_classes thì cache giải object không giới hạn — suy ra từ source CacheManager/RedisStore, chưa chạy. Hành vi của handleUnserializableClassUsing cũng mới đọc source.
  3. #[Locked] ném exception khi client sửa, checksum HMAC của snapshot và giới hạn số lần sai — docs Livewire + source Checksum.php nhánh main, chưa chạy. Cấu hình component cụ thể bị ảnh hưởng bởi CVE-2025-54068 không được mô tả chi tiết công khai trong advisory.
  4. Module Webhooks trong docs NestJS (scheme, tolerance, dedupe) — mới đọc docs qua Context7, chưa cài package.
  5. cosign verify trong pipeline, header Integrity-Policy và mức hỗ trợ của trình duyệt — theo docs (Sigstore, MDN), chưa chạy.
  6. Moodle: code_manager::get_remote_plugin_zip so MD5 và cờ disableupdateautodeploy — đọc MOODLE_405_STABLE; phiên bản và cấu hình thật của LMS Uni chưa biết. Dữ liệu phiên PHP của Moodle trong Redis ở dạng serialize — theo hiểu biết về session handler, chưa kiểm.
  7. BullMQ: phần job data qua JSON.stringify/JSON.parse đã tra docs; UnrecoverableError và chống trùng bằng jobId theo hiểu biết thư viện, chưa tra trong bài.
  8. Signed cookie của cookie-parser (Express) — theo hiểu biết, chưa tra docs trong bài.
  9. Chi phí mã hoá job, tốc độ kiểm chữ ký số so với HMAC — ước lượng, chưa đo.
  10. Presigned URL của MinIO — chưa tìm hiểu.
  11. Bộ rule SAST (Semgrep) cho các mẫu ở 6.2 — theo hiểu biết, chưa chạy.
  12. Các con số ví dụ (2 KB, độ sâu 8, ±5 phút, 30 phút, 2.000 webhook/ngày, 20% request) là minh hoạ, không phải số đo. Các điểm có dấu * ở mục 7 là giả định về hệ thống trong CV.

Nguồn

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 7 — A07 Authentication Failures · Bài 8 — A08 Software or Data Integrity Failures (bài này) · Tiếp: Bài 9 — A09 Security Logging and Alerting Failures →