Hình dung các header bảo mật như một bộ nội quy server dán ở cửa — nhưng người thật sự thi hành chúng là trình duyệt, đóng vai người gác cửa. Server chỉ viết ra luật rồi gửi kèm trang; trình duyệt mới là kẻ quyết định có tuân hay không. Giữ cái mô hình "server viết, trình duyệt thi hành" đó trong đầu, cả bài này sáng ra. Và header bảo mật là biện pháp rẻ nhất trong toàn bộ chặng: vài dòng cấu hình, không đổi mã.

Sáu header

.headers(h -> h
    .contentSecurityPolicy(c -> c.policyDirectives(
        "default-src 'self'; script-src 'self'; " +
        "style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; " +
        "frame-src https://www.youtube-nocookie.com; " +
        "object-src 'none'; base-uri 'self'; form-action 'self'"))
    .referrerPolicy(r -> r.policy(STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
    .httpStrictTransportSecurity(s -> s.includeSubDomains(true).maxAgeInSeconds(31536000))
    .frameOptions(f -> f.sameOrigin())
    .permissionsPolicy(p -> p.policy("geolocation=(), camera=(), microphone=()")))
  X-Content-Type-Options: nosniff
  X-XSS-Protection: 0
  X-Frame-Options: SAMEORIGIN
  Content-Security-Policy: default-src 'self'; script-src 'self'; ...
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), camera=(), microphone=()

Bài 39 đã đo bốn cái tự bật. Ba cái phải khai tay là CSP, Referrer-Policy và Permissions-Policy — và CSP là cái quan trọng nhất.

HSTS không xuất hiện

Chú ý danh sách trên không có Strict-Transport-Security, dù tôi đã cấu hình nó.

  qua HTTP thường                     : không có header
  với X-Forwarded-Proto: https        : Strict-Transport-Security: max-age=31536000 ; includeSubDomains

Spring chỉ gửi HSTS cho request HTTPS. Đúng đặc tả — dán cái luật "luôn dùng HTTPS" lên một kênh không an toàn thì vô nghĩa, kẻ đứng giữa xé nó xuống trước khi tới người gác cửa.

Hệ quả vận hành: ứng dụng chạy sau nginx thì nó chỉ thấy HTTP. Nếu nginx không chuyển tiếp X-Forwarded-Proto, HSTS bạn vừa cấu hình sẽ không bao giờ được gửi — và không có lỗi nào báo cho bạn biết.

Cần cả hai phía:

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header X-Real-IP         $remote_addr;
server:
  forward-headers-strategy: framework

CLAUDE.md của blog này ghi ba header đó là bắt buộc, và liệt kê hậu quả khi thiếu: bộ lọc IP và giới hạn tần suất mất tác dụng vì mọi khách trông như đến từ chính máy chủ, link trong email sinh ra sai giao thức — và giờ thêm một cái nữa: HSTS im lặng biến mất.

Và cẩn thận với includeSubDomains: nó ép mọi tên miền con dùng HTTPS trong một năm, kể cả những cái bạn chưa dựng. Trình duyệt đã nhớ thì không gỡ được từ phía máy chủ.

CSP: header đáng giá nhất

CSP nói cho trình duyệt biết được tải tài nguyên từ đâu — nó là danh sách khách mời cho script. Đây là lớp phòng thủ cuối cùng chống XSS: kể cả khi kẻ tấn công chèn được <script> vào trang, người gác cửa soát danh sách và từ chối cho nó chạy.

  default-src 'self'                          mặc định: chỉ cùng nguồn
  script-src 'self'                           KHÔNG có 'unsafe-inline'
  style-src 'self' https://cdn.jsdelivr.net
  img-src 'self' data:
  frame-src https://www.youtube-nocookie.com
  object-src 'none'                           chặn Flash, applet
  base-uri 'self'                             chặn cướp URL tương đối
  form-action 'self'                          form không gửi ra ngoài

Ba chỉ thị cuối hay bị bỏ sót và đều rẻ. base-uri 'self' đặc biệt đáng có: không có nó, kẻ chèn được một thẻ <base> sẽ đổi đích của mọi URL tương đối trên trang.

Cái giá: script nội tuyến không chạy

  script-src 'self'      <- không có 'unsafe-inline'

Đây là chỗ CSP đòi bạn đổi cách viết mã, và cũng là chỗ tôi thấy đáng cảnh báo nhất:

Mọi <script>…</script> và mọi thuộc tính onclick= viết thẳng trong template sẽ không chạy trên trình duyệt thật — mà test MockMvc thì vẫn xanh, vì nó không thực thi JavaScript.

Đây chính là "server viết, trình duyệt thi hành" cắn bạn: MockMvc không phải người gác cửa — không có trình duyệt thì không ai soát luật. Bài học này đã trả giá trong chính blog. Quy ước thay thế:

Thay vì Dùng
<script>…</script> tệp trong static/js/, nạp bằng th:src
onsubmit="return confirm('…')" data-confirm="…" xử lý ở một tệp JS chung
Nhúng giá trị từ server vào JS thẻ <meta> rồi đọc bằng JS

Blog này có hẳn một test — ContentSecurityPolicyTest — quét template tìm script nội tuyến, vì test chức năng không bao giờ bắt được.

Một ngoại lệ được giữ: <script type="application/ld+json"> cho JSON-LD. Đó là dữ liệu, trình duyệt không chạy nó như mã, và CSP không chặn.

Nới CSP cho đúng cách

Cần script nội tuyến thật (thư viện phân tích chẳng hạn), có ba mức:

  'unsafe-inline'          <- đừng, nó tắt gần hết tác dụng của CSP
  'nonce-<ngẫu nhiên>'     <- tốt, nhưng phải sinh mới MỖI response
  'sha256-<băm>'           <- tốt cho script tĩnh không đổi

Nonce dùng lại giữa các response thì vô dụng — kẻ tấn công chỉ cần đọc nó một lần.

Nhúng iframe: phải sửa hai chỗ

Đây là bài học riêng của blog này, và nó tốn kha khá thời gian:

Safelist của jsoup lọc được theo thẻ và giao thức, nhưng không lọc theo tên miền. Nên blog có thêm MarkdownService.dropUntrustedFrames quét lại sau khi làm sạch, đối chiếu với hằng TRUSTED_EMBED_HOSTS.

Thêm một nhà cung cấp video mới thì phải sửa cả hai chỗ: hằng số đó và frame-src trong CSP. Thiếu một trong hai là video không hiện mà chẳng có lỗi rõ ràng.

Triển khai CSP mà không phá trang

Đừng bật thẳng. Dùng chế độ chỉ báo cáo trước:

.contentSecurityPolicy(c -> c.policyDirectives("...").reportOnly())

Header thành Content-Security-Policy-Report-Only: trình duyệt không chặn gì, chỉ báo lỗi vào console và gửi báo cáo — người gác cửa ghi sổ mọi vi phạm nhưng vẫn cho vào. Chạy vài ngày, thu thập vi phạm, sửa, rồi mới bật thật.

Với trang đã chạy lâu, đây là cách duy nhất không làm gãy thứ gì.

Cache-Control: no-store cho mọi thứ

Bài 39 đã chỉ ra: Spring Security gắn no-store cho mọi phản hồi, kể cả CSS và ảnh.

@Bean WebSecurityCustomizer boQuaTinh() {
    return web -> web.ignoring().requestMatchers("/css/**", "/js/**", "/anh/**");
}

Chỉ làm với tài nguyên thật sự công khai. Đưa một endpoint có dữ liệu ra khỏi chuỗi lọc là bỏ luôn mọi kiểm tra bảo mật cho nó.

Đừng đặt header ở hai nơi

CLAUDE.md của blog này ghi rõ: nginx không đặt header bảo mật, vì ứng dụng đã tự gửi.

Đặt ở cả hai tầng là có hai giá trị cho cùng một header — trình duyệt chọn cái nào tuỳ trường hợp, và sửa một chỗ quên chỗ kia rất khó lần ra. Chọn một tầng, ghi vào tài liệu.

Muốn biết trang mình đang có gì, hỏi thẳng cái cửa trong vài giây:

curl -sI https://ung-dung-cua-ban.com/ | grep -iE "content-security|strict-transport|x-frame|referrer|permissions"

Thiếu Content-Security-Policy nghĩa là bạn không có lớp phòng thủ cuối cùng chống XSS. Thiếu Strict-Transport-Security trên URL https nghĩa là cấu hình của bạn không tới được ứng dụng — quay lại mục X-Forwarded-Proto ở trên.

Mẫu số chung

Mấy header này không phải của Spring — chúng là của nền tảng web, nên cùng bộ đó xuất hiện ở mọi framework, chỉ khác cách khai. Node có thư viện helmet (đúng nghĩa "đặt mấy header này"), Django có SecurityMiddleware và django-csp, Rails có gem secure_headers, ASP.NET có middleware tương ứng. Học một lần, mang đi khắp nơi.

Hai sự thật đáng khắc sâu, và cả hai đều bắt nguồn từ "server viết, trình duyệt thi hành".

Một: đây là gợi ý do trình duyệt thi hành, nên chúng bảo vệ phiên của người dùng, không bao giờ bảo vệ máy chủ. Server chỉ xin trình duyệt cư xử đúng; một client không tuân — curl, một công cụ của kẻ tấn công, một trình duyệt quá cũ — lờ sạch mọi header này. Vì thế đừng bao giờ lẫn CSP với phân quyền: chặn ở header không thay được kiểm tra quyền ở máy chủ. (Bài mai về CORS sẽ cho thấy đúng mặt này — trình duyệt chặn còn curl thì không.)

Hai: chúng là phòng thủ theo lớp. CSP là cái lưới hứng bên dưới việc thoát ký tự chống XSS; HSTS là lưới bên dưới "luôn dùng TLS". Mỗi header giả định rằng lớp phòng thủ chính có thể thủng — nên không bao giờ coi một header là thứ thay thế cho việc làm đúng biện pháp chính. Sợi chỉ chung: một header bảo mật là lời hứa bạn nhờ trình duyệt giữ hộ người dùng — mạnh khi làm lưới đỡ, vô dụng khi làm tuyến duy nhất, và vô hình với mọi bài test hay công cụ không phải là một trình duyệt thật.

Ngày mai: những lỗ hổng hay gặp nhất trong ứng dụng Spring, và cách tìm chúng trong mã của bạn.