Bài XSS trước kết thúc bằng một lời hứa: Content Security Policy là lớp phòng thủ thứ hai, đứng sau việc thoát ký tự. Phần này đo chính xác lớp đó — không phải bằng cách đọc tài liệu về CSP, mà bằng cách 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ã. - 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.
Bảng số liệu
Ba lần chạy, kết quả giống hệt. CHAY 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 |
Điều đáng nhớ
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ứ khô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' và 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' để 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. Lần đo đầu tiên của 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 kết luận sai 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 là 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: 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.
Một lần đo tự chặn chính mình
Lần dựng phép đo đầu tiên, tôi cho trang tự báo kết quả bằng một đoạn inline script: setTimeout(() => document.title = "KQ:" + ...). Nó chạy tốt 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 lại.
Cách sửa: đư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 — và đọ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 không phân biệt được "bị chặn" với "công cụ hỏng".
Vì sao
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 chúng bị chặn trừ khi có
'unsafe-inline'(hoặc một nonce/hash, mà tôi không đo ở đây). - selfjs có nguồn là chính máy chủ, khớp
'self'. - ngoai có nguồn là
evil:5001, chỉ chạy khi nguồn đó được liệt kê tường minh. - 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: đâ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ệ.
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.
Nghĩa là gì trong thực tế
- CSP là lớp thứ hai, không phải lớp duy nhất. Nó không thay thế việc thoát ký tự (bài trước) — nó là lưới an toàn cho khi việc thoát ký tự sót một chỗ. Có cả hai mới đủ.
'unsafe-inline'trongscript-srcgần như xoá sạch giá trị chống XSS. Nếu mã của bạn cần script inline, hãy chuyển sang nonce hoặc hash thay vì mở'unsafe-inline'— cả hai cho phép đúng những đoạn inline bạn tin, không phải mọi đoạn.- Tách từng chỉ thị.
default-srclà mặc định chung, nhưngscript-src,style-src,img-src,connect-srcnên đặt riêng — một chính sách chặt cho script không nên vô tình chặn cả ảnh. '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 bạ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ì.
Chỗ tôi không kết luận được
Tôi không đo nonce và hash — hai cách hiện đại để cho phép inline script cụ thể mà không mở 'unsafe-inline'. Chúng là cách đúng để giữ inline script khi bắt buộc, và xứng một bài riêng.
Tôi cũng chỉ đo script-src. CSP có hàng chục chỉ thị khác — style-src (chặn CSS injection), frame-ancestors (chống clickjacking), connect-src (chặn gửi dữ liệu ra ngoài) — mỗi cái bảo vệ một mặt khác. Và tôi đo bằng cách quan sát script có chạy không; CSP còn có chế độ report-only chỉ báo cáo vi phạm mà không chặn, hữu ích để thử nghiệm trước khi bật thật.
Thử ba mươi giây
Mở tab Network trong trình duyệt, xem header phản hồi của trang bạn. 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.