Hãy tưởng tượng một hộp đêm mà bảo vệ không cố đoán ai là kẻ gây rối — việc đó bất khả thi, kẻ xấu đâu có dán nhãn lên trán. Thay vào đó anh ta làm điều đơn giản hơn nhiều: chỉ cho vào những người có tên trong danh sách khách mời, và từ chối tất cả phần còn lại. Không cần biết kẻ lạ nguy hiểm ra sao, chỉ cần biết nó không có trong danh sách. Content Security Policy hoạt động y hệt cái bảo vệ đó, nhưng canh cửa cho từng đoạn JavaScript sắp chạy trên trang bạn. Bài XSS trước kết bằng một lời hứa rằng CSP là lớp phòng thủ thứ hai; bài này tôi đo chính xác lớp đó — không đọc tài liệu, mà dựng một trang có năm đường chạy JavaScript khác nhau, đổi header CSP, rồi dùng Chromium thật xem đường nào bị chặn.

Toàn bộ chạy trên ứng dụng tự dựng trong container, tự dọn; không nhắm vào hệ thống của ai.

Năm đường chạy script

Một trang có thể chạy JavaScript theo nhiều cách, và CSP đối xử với chúng khác nhau: inline (<script>...</script> viết thẳng trong trang), attr (thuộc tính sự kiện <img onerror="...">), selfjs (<script src="/self.js">, tệp từ cùng nguồn), eval (eval("...") chạy chuỗi như mã), và ngoai (<script src="http://evil:5001/x.js">, tệp từ nguồn ngoài). Với mỗi chính sách CSP, tôi đọc window.KQ sau khi trang chạy xong (qua kênh không bị CSP chặn) để biết đường nào thực thi được. Ba lần chạy, kết quả giống hệt — CHẠY nghĩa là đường đó không bị chặn:

script-src inline attr selfjs eval ngoai
(không có CSP) CHẠY CHẠY CHẠY CHẠY CHẠY
'self' chặn chặn CHẠY chặn chặn
'self' 'unsafe-inline' CHẠY CHẠY CHẠY chặn chặn
'self' 'unsafe-eval' chặn chặn CHẠY CHẠY chặn
'self' http://evil:5001 chặn chặn CHẠY chặn CHẠY
'none' chặn chặn chặn chặn chặn

Ba điều bảng này dạy

script-src 'self' làm nhiều hơn tên gọi của nó. Trực giác thường thấy: "'self' nghĩa là chỉ cho script từ cùng nguồn". Đúng, nhưng nó cũng chặn cả inline và eval — hai thứ chẳng phải "nguồn ngoài" chút nào. Đây chính là chỗ CSP thật sự chống được XSS: phần lớn payload XSS là inline (<script>alert()</script>) hoặc thuộc tính sự kiện (<img onerror>), và 'self' vô hiệu hoá cả hai. Ngay cả khi kẻ tấn công chèn được <script> vào trang, trình duyệt từ chối chạy nó vì nó không đến từ một tệp cùng nguồn.

'unsafe-inline' mở lại đúng cánh cửa mà CSP sinh ra để đóng. Nhìn dòng thứ ba: thêm 'unsafe-inline' là cả inline lẫn attr chạy lại. Nói cách khác, một CSP có 'unsafe-inline' không bảo vệ chống XSS — nó cho phép chính xác loại payload phổ biến nhất. Đây là lỗi cấu hình hay gặp nhất: người ta thêm 'unsafe-inline' cho mã cũ chạy được, và vô tình vứt bỏ toàn bộ giá trị của CSP.

'unsafe-eval' vô dụng nếu đứng một mình — và đây là kết quả đi ngược trực giác nhất, đến mức suýt khiến tôi kết luận sai. Lần đo đầu tiên tôi đặt eval() trong một inline script. Với 'self' 'unsafe-eval', tôi thấy eval bị chặn, và suýt nữa viết rằng 'unsafe-eval' không có tác dụng. Sự thật tinh tế hơn: inline script bị 'self' chặn trước, nên đoạn mã gọi eval không bao giờ chạy tới. 'unsafe-eval' chỉ cho phép eval khi mã gọi nó được phép chạy — tức nằm trong một tệp cùng nguồn. Tôi phải chuyển eval() vào /self.js mới đo đúng: khi đó 'self' chặn eval, còn 'self' 'unsafe-eval' cho eval chạy. Bài học là các chỉ thị CSP tương tác với nhau, và một chỉ thị chỉ có ý nghĩa khi có đường cho mã liên quan chạy tới.

Khi công cụ đo bị chính thứ nó đo chặn lại

Có một pha đáng nhớ trong lúc dựng phép đo mà tôi muốn kể, vì nó là một cái bẫy sâu. Lần đầu, tôi cho trang tự báo kết quả bằng một đoạn inline script: setTimeout(() => document.title = "KQ:" + ...). Chạy ngon khi không có CSP. Nhưng ngay khi bật script-src 'self', mọi ô trong bảng thành "không đọc được kết quả" — vì chính đoạn script báo cáo cũng là inline, và cũng bị CSP chặn. Công cụ đo bị đúng thứ nó đang đo chặn mất.

Cách sửa là đưa kênh báo cáo ra ngoài tầm với của CSP. Tôi khởi tạo biến kết quả bằng add_init_script của Playwright — chạy qua giao thức điều khiển trình duyệt, trước khi trang tải, không phải một script của trang nên CSP không đụng tới — rồi đọc lại bằng page.evaluate. Đây là một nguyên tắc chung khi đo cơ chế bảo mật: dụng cụ đo phải nằm ngoài cơ chế bị đo, nếu không bạn chẳng phân biệt được "bị chặn" với "công cụ hỏng".

Vì sao CSP chặn được cả những thứ nó chưa từng thấy

CSP không lọc nội dung — nó kiểm nguồn gốc của mỗi lần thực thi. Trình duyệt hỏi, với mỗi đoạn script sắp chạy: mày đến từ đâu, và chính sách có cho phép nguồn đó không? inline và attr không có "nguồn" theo nghĩa URL, nên bị chặn trừ khi có 'unsafe-inline' (hoặc một nonce/hash, tôi không đo ở đây). selfjs có nguồn là chính máy chủ, khớp 'self'. ngoai có nguồn evil:5001, chỉ chạy khi nguồn đó được liệt kê tường minh. Còn eval không phải chuyện nguồn mà là chuyện biến chuỗi thành mã, nên nó có cờ riêng 'unsafe-eval'.

Điểm mấu chốt là đây là mô hình danh sách trắng theo nguồn, đúng nguyên tắc đã thấy ở các bài injection — CSP không cố đoán payload nào nguy hiểm, nó chỉ cho phép mã từ những nơi bạn khai trước và từ chối tất cả còn lại. Đó là lý do nó chặn được cả <svg onload> hay <iframe srcdoc> — những biến thể tôi không hề đưa vào phép đo — miễn là chúng cố chạy mã inline: chính sách không cần biết chúng, chỉ cần biết chúng không có nguồn hợp lệ, đúng như bảo vệ hộp đêm không cần biết tên kẻ lạ, chỉ cần biết nó không trong danh sách. Dòng cuối bảng, 'none', chặn cả selfjs — kể cả script cùng nguồn hợp lệ; nó là chính sách chặt nhất có thể, dùng cho những trang không cần một dòng JavaScript nào như trang lỗi tĩnh hay trang đích.

Ra thực tế thì nhớ bốn điều. CSP là lớp thứ hai, không phải lớp duy nhất — nó không thay việc thoát ký tự mà là lưới an toàn cho khi việc thoát sót một chỗ, có cả hai mới đủ. 'unsafe-inline' trong script-src gần như xoá sạch giá trị chống XSS, nên nếu mã của bạn cần script inline thì chuyển sang nonce hoặc hash thay vì mở 'unsafe-inline' — cả hai chỉ cho phép đúng những đoạn inline bạn tin. Tách từng chỉ thị: default-src là mặc định chung, nhưng script-src, style-src, img-src, connect-src nên đặt riêng để một chính sách chặt cho script không vô tình chặn cả ảnh. Và 'none' là điểm khởi đầu tốt — bắt đầu bằng chặn tất cả rồi mở đúng những nguồn thật sự dùng, an toàn hơn nhiều so với bắt đầu bằng cho phép rồi cố nhớ chặn cái gì.

Mẫu số chung

Cái lõi "liệt kê cái được phép, từ chối phần còn lại" thay vì "cố đoán cái nguy hiểm để chặn" không riêng gì CSP — nó là lằn ranh giữa allowlist và blocklist, và allowlist luôn thắng ở đúng chỗ này.

Trong thế giới Java, đây đúng là cách Spring Security dựng header: http.headers(h -> h.contentSecurityPolicy(c -> c.policyDirectives("script-src 'self'"))), và các ứng dụng nghiêm túc bỏ hẳn 'unsafe-inline', chuyển JS ra file tĩnh nạp bằng nonce do server sinh mỗi request. Cùng tinh thần allowlist lặp lại khắp Java: ObjectInputFilter liệt kê lớp được phép giải tuần tự hoá thay vì đoán lớp nào là gadget, jsoup Safelist liệt kê thẻ được phép thay vì đoán thẻ nào độc.

Trong Go, một middleware đặt header CSP (như secure của unrolled) làm y hệt, và triết lý allowlist thấm vào cả html/template — nó mặc định thoát mọi thứ theo ngữ cảnh và chỉ cho bạn đánh dấu template.HTML những đoạn tin cậy tường minh, tức là an-toàn-mặc-định rồi mở đúng chỗ cần, giống hệt bắt đầu từ 'none'.

Còn ở cơ sở dữ liệu, đây là lựa chọn giữa cho ứng dụng chạy bất kỳ SQL nào rồi cố lọc câu độc, đối lại chỉ cho gọi một tập stored procedure/prepared statement được phép — cái sau là allowlist, và nó chặn được cả những câu tấn công chưa ai nghĩ ra, đúng như CSP chặn <svg onload> mà không cần biết trước về nó. GRANT EXECUTE trên đúng vài hàm còn REVOKE mọi quyền bảng trực tiếp là bản CSP của tầng dữ liệu.

Mẫu số chung: một blocklist phải đoán đúng mọi cái xấu và chỉ cần sót một là thủng, còn một allowlist chỉ cần liệt kê đúng cái tốt (thường hữu hạn và bạn biết rõ) rồi từ chối phần còn lại — nên nó chặn được cả những đòn tấn công chưa tồn tại lúc bạn viết luật. Vì thế câu hỏi đúng khi thiết kế bất kỳ lớp lọc nào không phải "tôi đã chặn hết cái xấu chưa" — câu đó không bao giờ trả lời xong — mà là "tôi đã liệt kê được cái tốt và từ chối mặc định phần còn lại chưa"; chỉ vế sau mới cho bạn ngủ yên trước một payload bạn chưa từng thấy.

Muốn tự soi trong ba mươi giây, mở tab Network trong trình duyệt, xem header phản hồi của trang bạn và tìm dòng Content-Security-Policy. Nếu không có, trang bạn không có lớp phòng thủ thứ hai nào cho XSS. Nếu có mà chứa 'unsafe-inline' trong script-src, hãy biết rằng phần chống XSS của nó gần như bằng không — nó cho phép đúng loại payload mà một CSP đáng lẽ phải chặn.