Content Security Policy (CSP) không sửa lỗ hổng XSS, nhưng có thể biến một điểm chèn mã thành một cuộc tấn công khó thực thi hơn nhiều. Thay vì chỉ tin các domain trong allowlist, Strict CSP cấp quyền cho từng script bằng nonce hoặc hash, rồi dùng 'strict-dynamic' để truyền niềm tin có kiểm soát cho các script phụ thuộc được tải động.
Bài này xây dựng một chính sách thực dụng cho ứng dụng render phía server, giải thích khi nào nên dùng hash, cách rollout mà không làm hỏng production và những lỗi thường khiến CSP chỉ “có header” nhưng không tạo ra lớp phòng thủ đáng kể.
1. CSP nằm ở đâu trong mô hình phòng thủ XSS?
Trình duyệt nhận CSP qua response header Content-Security-Policy và dùng nó để quyết định tài nguyên, script hoặc hành vi inline nào được phép. Đây là lớp defense in depth: ứng dụng vẫn phải encode output theo đúng ngữ cảnh, sanitize HTML không tin cậy, tránh sink nguy hiểm và vá nguyên nhân gốc của XSS.
Một chính sách kiểu script-src 'self' https://cdn.example thường dễ triển khai nhưng đặt niềm tin vào toàn bộ origin. Nếu domain được cho phép có JSONP, upload hoặc endpoint trả nội dung do người dùng kiểm soát, allowlist có thể bị lợi dụng. Strict CSP chuyển đơn vị tin cậy từ “domain” sang “script cụ thể được server phê duyệt”.
CSP tốt là dây an toàn, không phải giấy phép bỏ qua việc encode và sanitize. Nếu ứng dụng có XSS, hãy sửa XSS ngay cả khi trình duyệt đã chặn payload thử nghiệm.
2. Ba mảnh ghép: nonce, hash và strict-dynamic
| Cơ chế | Phù hợp khi | Điểm phải kiểm soát |
|---|---|---|
'nonce-…' | HTML được render động phía server | Giá trị ngẫu nhiên mới cho mỗi response, gắn đúng vào mọi script tin cậy |
'sha256-…' | HTML/script inline ổn định, nội dung tĩnh hoặc cache dài | Hash phải khớp chính xác byte của block script; thay khoảng trắng cũng có thể làm đổi hash |
'strict-dynamic' | Script gốc tin cậy cần tải dependency động | Audit mọi URL được đưa vào API tạo script; dữ liệu không tin cậy không được quyết định src |
Theo Content Security Policy Level 3 của W3C, nonce phải không đoán được, được sinh mới mỗi lần policy được truyền và nên có ít nhất 128 bit trước khi encode. Cùng một nonce có thể dùng cho các thẻ <script> được tin cậy trong một response, nhưng không được tái sử dụng cho response tiếp theo.
Khi 'strict-dynamic' có mặt, trình duyệt hỗ trợ CSP3 bỏ qua host-source, scheme-source và 'self' trong script-src; nonce/hash vẫn được tôn trọng. Script đã được phê duyệt có thể tạo script mới bằng DOM API. Script parser-inserted, chẳng hạn được tạo qua document.write(), không tự nhận chuỗi tin cậy này.
3. Chính sách tối thiểu cho ứng dụng render phía server
Content-Security-Policy:
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
Đây là điểm xuất phát, không phải policy để sao chép mù quáng. Ứng dụng dùng API ngoài, WebSocket, CDN ảnh, iframe thanh toán hoặc font bên thứ ba cần các directive tương ứng. Bắt đầu với default-src 'none' giúp tài nguyên chưa được phân loại bị từ chối thay vì âm thầm rơi vào một mặc định rộng.
object-src 'none'loại bỏ plugin object/embed cũ khỏi bề mặt tấn công.base-uri 'none'ngăn phần tử<base>bị chèn làm đổi cách URL tương đối được phân giải.frame-ancestors 'none'kiểm soát ai được nhúng trang; directive này cần được khai báo riêng.form-action 'self'giới hạn đích gửi form, hữu ích nếu markup bị can thiệp.
Hướng dẫn Strict CSP của web.dev cũng lấy nonce/hash, 'strict-dynamic', object-src 'none' và base-uri làm cấu trúc nền. Hãy mở rộng theo luồng dữ liệu thật thay vì thêm wildcard cho nhanh.
4. Tạo nonce đúng cách trong PHP
<?php
$nonce = base64_encode(random_bytes(16));
$policy = implode('; ', [
"default-src 'none'",
"script-src 'nonce-{$nonce}' 'strict-dynamic'",
"style-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
]);
header('Content-Security-Policy: '.$policy);
// Truyền $nonce vào template, không nhận từ request.
?>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>"
src="/assets/app.js"></script>
random_bytes(16) tạo 128 bit từ nguồn ngẫu nhiên mật mã; Base64 biến dữ liệu thành chuỗi hợp lệ để dùng trong nonce-source. Nonce xuất hiện trong HTML là bình thường: bí mật không phải mục tiêu của nó. Điều quan trọng là kẻ tấn công không đoán được giá trị trước khi response được tạo và không thể lấy một giá trị cũ để dùng lại.
Đừng lấy nonce từ query string, cookie, ID phiên hoặc timestamp. Đừng sinh một nonce lúc process khởi động rồi dùng mãi. Nếu template engine tự động thêm nonce, hãy chắc rằng nó chỉ gắn vào script do ứng dụng kiểm soát; tự động chép nonce vào mọi thẻ script trong HTML không tin cậy sẽ phê duyệt luôn payload của kẻ tấn công.
5. Nonce và cache: lỗi triển khai dễ bị bỏ qua nhất
HTML có nonce theo từng response không thể được cache như một file bất biến mà không có chiến lược rõ ràng. Nếu CDN lưu cả header và body rồi phục vụ lại, nonce có thể bị tái sử dụng. Nếu một lớp chỉ thay header nhưng không thay attribute trong HTML, policy và markup không khớp, khiến ứng dụng tự chặn script của mình.
- Không shared-cache HTML chứa nonce, hoặc để edge tạo đồng bộ nonce mới cho cả header và body.
- Cache asset JavaScript bằng URL có version; nonce nằm trên thẻ tải asset chứ không làm asset mất khả năng cache.
- Kiểm tra cả cache HIT và MISS, nhiều route, response lỗi và trang đăng nhập.
- Refresh nhiều lần và xác nhận nonce thay đổi nhưng mọi nonce trong cùng response vẫn khớp header.
Nếu sản phẩm chủ yếu là static HTML được CDN phục vụ nguyên trạng, hash thường phù hợp hơn nonce vì không cần thay đổi mỗi response.
6. Dùng hash cho script inline ổn định
Với một block script cố định, tính SHA-256 trên đúng nội dung bên trong thẻ rồi Base64-encode digest:
printf '%s' 'window.appBoot()' \
| openssl dgst -sha256 -binary \
| openssl base64
# Header minh họa:
Content-Security-Policy: script-src 'sha256-{BASE64_DIGEST}' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script>window.appBoot()</script>
Hash xác thực nội dung, nên một ký tự xuống dòng, khoảng trắng hoặc bước minify khác có thể làm digest không còn khớp. Hãy tạo hash trong build pipeline từ artifact cuối cùng, không chép thủ công từ source chưa build. Khi có nhiều block inline, mỗi block cần hash riêng hoặc nên gom logic vào bundle bên ngoài.
Đừng nhầm CSP hash với Subresource Integrity (SRI). Hai cơ chế có thể cùng dùng hash nhưng giải quyết các quyết định khác nhau: CSP quyết định script nào được phép thực thi trong trang, còn SRI kiểm tra nội dung tài nguyên tải từ một URL có đúng digest kỳ vọng hay không.
7. strict-dynamic thuận tiện, nhưng niềm tin có thể lan sai
// app.js đã được phép bằng nonce
const script = document.createElement('script');
script.src = '/assets/chunk-dashboard.js';
document.head.appendChild(script);
Với 'strict-dynamic', chunk được script tin cậy tạo ra có thể chạy mà không cần liệt kê host trong policy. Điều này phù hợp với bundler và loader hiện đại. Nhưng nếu URL đến từ dữ liệu không tin cậy, chuỗi tin cậy trở thành đường vòng:
// Nguy hiểm: tham số URL quyết định script sẽ tải.
const next = new URL(location.href).searchParams.get('module');
const script = document.createElement('script');
script.src = next;
document.head.appendChild(script);
Hãy dùng mapping cố định từ định danh nghiệp vụ sang URL đã biết, tránh nối chuỗi và audit các API như createElement('script'), appendChild, dynamic import và loader của bên thứ ba. CSP không làm sạch URL do attacker kiểm soát.
8. Rollout bằng Report-Only, không “học” policy một cách mù quáng
Trước khi enforce, gửi cùng policy dưới header Content-Security-Policy-Report-Only. Trình duyệt ghi nhận vi phạm nhưng chưa chặn tài nguyên. Dùng DevTools, test tự động và reporting endpoint để lập inventory:
Content-Security-Policy-Report-Only:
default-src 'none';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
report-to csp-endpoint;
Report-Only không tạo bảo vệ; nó chỉ hỗ trợ quan sát. Báo cáo từ client là dữ liệu không tin cậy: endpoint phải giới hạn kích thước, rate-limit, xác thực schema và tránh ghi thẳng payload vào log hoặc dashboard. URL trong báo cáo cũng có thể mang dữ liệu nhạy cảm, vì vậy cần chính sách lưu trữ và loại bỏ phù hợp.
Không tự động đưa mọi host xuất hiện trong report vào allowlist. Extension trình duyệt, malware cục bộ, thử nghiệm tấn công và tài nguyên đã bị bỏ quên đều tạo noise. Mỗi ngoại lệ phải có owner, lý do nghiệp vụ và ngày xem xét lại.
9. Kế hoạch chuyển đổi một ứng dụng đang chạy
- Lập inventory: tìm inline script, event handler như
onclick,javascript:URL,eval, loader động và dịch vụ bên thứ ba. - Di chuyển code: đưa handler vào
addEventListener, tách script ổn định thành bundle, bỏ dependency không còn dùng. - Chọn nền tảng: nonce cho server-rendered HTML; hash cho static HTML; có thể kết hợp khi có lý do rõ ràng.
- Chạy Report-Only: bao phủ route quan trọng, browser hỗ trợ, trạng thái đăng nhập và các luồng thanh toán/đăng ký.
- Enforce theo lát nhỏ: bắt đầu ở route ít rủi ro hoặc một tỷ lệ traffic, theo dõi lỗi JavaScript và chuyển đổi nghiệp vụ.
- Siết dần: bỏ host thừa, wildcard,
'unsafe-inline'và'unsafe-eval'thay vì để policy tạm thời tồn tại vĩnh viễn.
Nếu phải hỗ trợ browser cũ, policy có thể cần fallback như https: hoặc 'unsafe-inline'. Trình duyệt hiện đại hỗ trợ nonce/hash và 'strict-dynamic' sẽ bỏ qua các fallback tương ứng, nhưng browser cũ nhận chính sách yếu hơn. Hãy chỉ thêm chúng khi dữ liệu người dùng thực tế chứng minh nhu cầu và ghi rõ ngày loại bỏ.
10. Bộ kiểm thử trước khi bật enforce
- Response có đúng một CSP dự kiến sau CDN/reverse proxy; không bị ghép với header cấu hình ở lớp khác.
- Mỗi response có nonce mới, đủ entropy; header và toàn bộ script tin cậy trong body khớp nhau.
- Script không nonce, inline event handler và payload XSS thử nghiệm bị chặn.
- Bundle, lazy-loaded chunk, worker, API, font, ảnh và luồng đăng nhập vẫn hoạt động.
- Cache HIT/MISS, trang lỗi, redirect, HTML streaming và hydration đều được kiểm tra.
- Report endpoint chịu được traffic bất thường, không phản chiếu payload và có cảnh báo theo tỷ lệ vi phạm.
- Có nút rollback nhanh nhưng không hạ policy xuống wildcard vô thời hạn.
Mục tiêu không phải là đạt một header thật dài, mà là tạo ranh giới tin cậy nhỏ, có thể giải thích và kiểm chứng. Với HTML động, nonce ngẫu nhiên theo response cộng 'strict-dynamic' thường là đường triển khai dễ bảo trì. Với trang tĩnh, hash giúp giữ cache hiệu quả. Dù chọn cách nào, rollout có quan sát và test hành vi thật mới biến CSP từ cấu hình trang trí thành lớp bảo vệ có giá trị.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.