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-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ả — 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ó.
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:
<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ẻ 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. 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.