Header bảo mật là biện pháp rẻ nhất trong toàn bộ chặng này: 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-PolicyPermissions-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ả — gửi HSTS qua kết nối không an toàn là vô nghĩa vì kẻ đứng giữa xoá được nó.

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à 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, CSP chặn 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 là bài học đã trả giá trong chính blog này. 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ẻ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ố đó 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. 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.

Thử ba mươ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.

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.