Rất nhiều lỗ hổng web nghiêm trọng — clickjacking, một phần XSS, tấn công hạ cấp HTTPS — không cần sửa một dòng code ứng dụng nào để phòng: chỉ cần server gửi đúng vài header bảo mật. Chúng là các chỉ thị trong response nói cho trình duyệt "hãy siết chặt theo cách này". Điều khiến chúng dễ bị bỏ qua cũng chính là điều khiến chúng mạnh: người thi hành không phải server, mà là trình duyệt. Bài này (phần 18, khép lại loạt "HTTP và web đằng sau") xem các header thật mà chính blog này đang gửi, rồi dùng một trình duyệt Chromium thật để chứng kiến nó thi hành một header.

Bốn header cốt lõi và mỗi cái chặn gì

Strict-Transport-Security: max-age=31536000; includeSubDomains   # ép HTTPS
Content-Security-Policy: default-src 'self'; script-src 'self' ... # chặn XSS
X-Frame-Options: SAMEORIGIN                                       # chống clickjacking
X-Content-Type-Options: nosniff                                   # chặn đoán kiểu file
  • HSTS (Strict-Transport-Security): sau lần đầu thấy header này, trình duyệt ghi nhớ rằng trong max-age giây tới (đây là một năm) chỉ được vào site qua HTTPS — kể cả khi người dùng gõ http://. Nó chặn tấn công hạ cấp (kẻ tấn công ép kết nối về HTTP để nghe lén). includeSubDomains mở rộng luật này cho mọi tên miền con.
  • CSP (Content-Security-Policy): khai báo nguồn nào được phép nạp script, style, ảnh, iframe. Một script lạ bị chèn vào trang (XSS) mà không khớp script-src sẽ bị trình duyệt từ chối chạy. Đây là lớp phòng thủ XSS mạnh nhất ở tầng trình duyệt.
  • X-Frame-Options / CSP frame-ancestors: cấm site khác nhúng trang bạn vào <iframe>. Ngăn clickjacking — kẻ xấu phủ trang bạn dưới một lớp trong suốt rồi lừa người dùng bấm nhầm nút thật.
  • X-Content-Type-Options: nosniff: cấm trình duyệt "đoán" kiểu nội dung khác với Content-Type server khai. Chặn kiểu tấn công tải lên file trông vô hại nhưng bị trình duyệt hiểu và chạy như HTML/JS.

Ảnh chụp đoạn mã nền tối minh hoạ header bảo mật mỗi dòng chặn một kiểu tấn công, Strict-Transport-Security HSTS ép HTTPS max-age 31536000 includeSubDomains trình duyệt nhớ một năm tới chỉ vào site này qua HTTPS chặn tấn công hạ cấp về HTTP và nghe lén, Content-Security-Policy CSP khoá nguồn tài nguyên chặn XSS default-src self script-src self chỉ cho chạy script từ nguồn khai báo script lạ chèn vào XSS không khớp trình duyệt từ chối chạy object-src none, X-Frame-Options frame-ancestors chống clickjacking DENY hoặc SAMEORIGIN cấm site khác nhúng trang bạn vào iframe rồi lừa người dùng bấm nhầm CSP frame-ancestors là bản mới hơn, X-Content-Type-Options nosniff cấm trình duyệt đoán kiểu file khác với Content-Type chặn file txt chứa mã bị chạy như HTML JS Referrer-Policy giới hạn thông tin Referer gửi sang site khác

Hình 1: Bốn header bảo mật cốt lõi — HSTS ép HTTPS, CSP khoá nguồn tài nguyên để chặn XSS, X-Frame-Options chống clickjacking, nosniff cấm đoán kiểu file — mỗi cái nhắm một lớp tấn công riêng.

Đo thật: header chính blog này đang gửi

Đây không phải ví dụ giả định — chính blog coffeecode.vn gửi đủ bộ. Lấy bằng curl -I:

$ curl -I https://coffeecode.vn/
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self' ...
   frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin

Chú ý CSP ở đây có script-src 'self' không kèm 'unsafe-inline' — nghĩa là mọi <script> viết thẳng trong HTML hay thuộc tính onclick= đều bị chặn, buộc code phải nằm trong file .js riêng. Đây là lý do blog này không dùng script nội tuyến. Cộng thêm object-src 'none' (cấm Flash/plugin), base-uri 'self' và form-action 'self' (chặn cướp form).

Chứng kiến trình duyệt thi hành: X-Frame-Options thật

Header chỉ có nghĩa khi trình duyệt thực sự thi hành. Để không dừng ở mô tả, mình dựng một nginx phục vụ hai trang: open.html (không header) và deny.html (X-Frame-Options: DENY), rồi một trang cha nhúng cả hai vào <iframe>. Sau đó nạp trang cha bằng Chromium thật (headless) và đọc nội dung từng iframe:

Ảnh chụp bảng kết quả chạy thật header của blog cộng Chromium thi hành output thật, một header bảo mật thật coffeecode.vn gửi curl -I Strict-Transport-Security max-age 31536000 includeSubDomains Content-Security-Policy default-src self script-src self frame-ancestors self object-src none base-uri self form-action self X-Frame-Options SAMEORIGIN X-Content-Type-Options nosniff Referrer-Policy strict-origin-when-cross-origin, hai Chromium thật nhúng 2 iframe một mở một DENY f1 open.html không header nội dung TRANG MO cho phep nhung iframe f2 deny.html X-Frame-Options DENY nội dung RỖNG bị chặn, ba chính lời từ chối của trình duyệt console error thật Refused to display http 127.0.0.1 8095 in a frame because it set X-Frame-Options to deny đây là Chromium thật thi hành không phải mô tả trang DENY không hiện trong iframe clickjacking bất khả thi

Hình 2: Chạy thật — trang không header nạp vào iframe bình thường (f1 đọc được "TRANG MO..."); trang có X-Frame-Options: DENY bị rỗng (f2), kèm chính lời từ chối của Chromium: "Refused to display ... because it set 'X-Frame-Options' to 'deny'".

Kết quả rõ ràng và do trình duyệt tự thi hành, không phải mình mô tả:

  • f1 (open.html, không header): iframe nạp thành công, đọc được nội dung "TRANG MO: cho phep nhung iframe".
  • f2 (deny.html, X-Frame-Options: DENY): iframe rỗng — Chromium từ chối hiển thị, và ghi console error nguyên văn: Refused to display 'http://127.0.0.1:8095/' in a frame because it set 'X-Frame-Options' to 'deny'.

Trang DENY có tồn tại và trả về 200 OK (curl lấy được bình thường) — nhưng trình duyệt không cho nó hiện trong khung của trang khác. Đó chính xác là cách clickjacking bị vô hiệu hoá: kẻ tấn công không thể phủ trang thật của bạn lên bẫy của họ.

Đánh đổi cần cân nhắc

CSP mạnh nhưng khó triển khai đúng — dùng report-only trước. Một CSP quá chặt sẽ chặn nhầm chính tài nguyên hợp lệ của bạn (font, analytics, ảnh CDN) và trang vỡ âm thầm. Cách an toàn: bật Content-Security-Policy-Report-Only trước, thu thập báo cáo vi phạm để biết cần cho phép nguồn nào, rồi mới chuyển sang chế độ thực thi. Và nhớ: mọi thay đổi CSP phải kiểm trên trình duyệt thật vì công cụ test phía server thường không thi hành CSP.

HSTS khó thu hồi — cân nhắc trước khi bật includeSubDomains và preload. Vì trình duyệt nhớ HSTS tới một năm, nếu lỡ bật cho một tên miền con chưa sẵn sàng HTTPS, người dùng sẽ không vào được và bạn không thể "gỡ nhanh" — phải đợi max-age hết hoặc người dùng tự xoá. Bắt đầu với max-age nhỏ, tăng dần khi chắc chắn.

Đừng đặt cùng header ở hai tầng. Nếu cả nginx lẫn ứng dụng cùng gửi CSP hay HSTS, response có hai giá trị cho một header — sửa một chỗ quên chỗ kia rất khó lần. Chọn một tầng chịu trách nhiệm (blog này để ứng dụng tự gửi, nginx không đụng vào) và giữ nguyên tắc đó.

Ba ý mang về

  1. Header bảo mật chặn cả lớp tấn công mà không cần sửa code ứng dụng: HSTS ép HTTPS (chống hạ cấp), CSP khoá nguồn tài nguyên (chống XSS), X-Frame-Options chống clickjacking, nosniff cấm đoán kiểu file — người thi hành là trình duyệt.
  2. Đây là cấu hình thật, không lý thuyết: đo thật header coffeecode.vn gửi, với script-src 'self' không unsafe-inline, object-src 'none', frame-ancestors 'self' — nên mọi script phải nằm trong file riêng.
  3. Trình duyệt thật sự thi hành: đo bằng Chromium thật, trang có X-Frame-Options: DENY bị từ chối hiển thị trong iframe (console: "Refused to display ... to 'deny'") dù vẫn trả 200 — đó là clickjacking bị vô hiệu hoá tận gốc.

Nguồn

Đến đây khép lại loạt "HTTP và web đằng sau" — từ dòng request đầu tiên, status code, keep-alive, HTTP/2, cache, cookie, nén, TLS và X.509, reverse proxy, CORS, xác thực, range request, WebSocket, cho tới các header bảo mật ở bài này. Điểm chung xuyên suốt: HTTP không phải hộp đen — mỗi hành vi của trình duyệt đều bắt nguồn từ một header hay một quy tắc bạn có thể đo, kiểm và hiểu bằng curl cùng một trình duyệt thật. Nắm được lớp này, bạn debug web nhanh hơn và thiết kế hệ thống an toàn hơn.