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.
Phần 2/10 trong series Web Security for Frontend Devs. Trước: Tiếp:
Đâ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. 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 1 bạn đã học Same-Origin Policy cô lập các origin với nhau. 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ọ. 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 đủ.
Vì sao XSS là mối đe dọa frontend số 1
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. Script được inject chạy với danh tính nạn nhân trên origin của bạn:
- Đọc mọi thứ trên trang người dùng nhìn thấy (DOM, state trong bộ nhớ).
- 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).
- Đánh cắp cookie phiên hoặc token trong
localStorage— trừ khi bạn xếp lớp phòng thủ khác (Phần 5). - Keylog, phá giao diện, leo thang lên admin nếu nạn nhân có quyền cao.
Mô hình tư duy: 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.
Ba kiểu XSS
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 (lưu lâu dài)
Payload lưu trên server (DB, file, cache) rồi phục vụ cho người dùng khác. Ví dụ kinh điển: ô comment, bio profile, ticket hỗ trợ, nội dung CMS. Một lần inject có thể ảnh hưởng mọi người xem trang đó.
Reflected (không lưu)
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. Nạn nhân phải mở link được tạo sẵn (hoặc gửi form). Phạm vi nhỏ hơn stored, vẫn hay gặp ở ô tìm kiếm và lỗi đăng nhập.
DOM-based
Server không bao giờ thấy payload độc. 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 thấy HTML server sạch; lỗ hổng nằm hoàn toàn trong JS của bạn.
Sink nguy hiểm — nơi code thật sự chạy
Sink là API nào coi chuỗi là HTML hoặc JavaScript. 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.
Sink HTML DOM:
element.innerHTML,outerHTML,insertAdjacentHTMLdocument.write,document.writeln
Sink thực thi JavaScript:
eval,new Function(string)setTimeout(string),setInterval(string)— the string overload, not the callback form
Sink URL / điều hướng:
<a href="javascript:...">,location.href = userInput,window.open(untrusted)
Cửa thoát framework (tắt escape tự động):
- React:
dangerouslySetInnerHTML - Vue:
v-html - Angular:
[innerHTML],bypassSecurityTrustHtml,bypassSecurityTrustUrl, …
Rà soát codebase với các symbol này như bạn rà eval. Mỗi chỗ cần lý do ghi rõ và luồng dữ liệu an toàn.
Mẫu lỗ hổng — và cách sửa
Lỗi kinh điển
// ❌ VULNERABLE — treats user comment as HTML
const el = document.getElementById('comment');
el.innerHTML = userComment;
Nếu userComment là <img src=x onerror="...">, trình duyệt sẽ chạy.
Sửa 1: coi dữ liệu không tin cậy là text
// ✅ SAFE — no HTML parsing
el.textContent = userComment;
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>
Chỉ dùng escape hatch khi cố ý cần markup — và khi đó phải sanitize.
Sửa 2: khi thật sự cần HTML — sanitize
Editor rich text, Markdown render, field CMS cũ cần tập con HTML. 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);
Sanitize phía server (khi ghi) vẫn bắt buộc cho stored XSS — client có thể bị bỏ qua. Sanitize client là phòng thủ nhiều lớp cho DOM-based và lỗi encode kép.
Sửa 3: ngữ cảnh quan trọng — đừng nối chuỗi HTML
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);
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 — chặn sink nguy hiểm
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. 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
}
}
Chuỗi thô gán vào innerHTML ném lỗi runtime — buộc mọi sink qua policy đã audit. Bắt regression trong review và CI.
Phòng thủ nhiều lớp — XSS không thể phá hết một mình
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) — Phần 3: hạn chế script nào được chạy, chặn handler inline, báo cáo vi phạm. CSP không thay encode output, nhưng giới hạn thiệt hại khi một sink lọt.
Cookie phiên httpOnly — Phần 5: nếu token phiên chỉ trong cookie httpOnly, document.cookie và nhiều snippet đánh cắp không đọc được gì. 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.
SOP từ Phần 1 không chặn XSS trên trang của bạn — script độc đã cùng origin. 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ộ.
Checklist phòng tránh
Trước khi ship tính năng hiển thị nội dung người dùng:
- Mặc định text, không HTML.
- Nếu cần HTML, sanitize.
- Đừng dựng HTML bằng template literal + dữ liệu không tin cậy.
- Chặn
javascript:trong URL. - Bật CSP và cân nhắc Trusted Types trên app rủi ro cao.
- Lưu token phiên trong cookie httpOnly khi có thể (Phần 5).
Bài tập / Exercises
1. 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.
Lời giải
Stored — comment lưu DB, sink template server hoặc render HTML lưu.
Reflected — payload trong request, phản hồi một lần.
DOM-based — server có thể không thấy fragment; sink innerHTML trong router client.
2. 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;
}
Lời giải
Nếu bio chỉ là text thuần:
function showBio(el, bio) {
el.textContent = bio;
}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);
}Dùng textContent khi không cần markup; sanitize khi cần.
3. 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
Lời giải
An toàn — text node.
Không an toàn — sink HTML.
An toàn — truyền data, không compile thành code.
Không an toàn — overload chuỗi như eval.
Không an toàn nếu không check scheme — javascript: chạy được.
Nâng cao: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.
Lời giải
Phục vụ CSP với trusted-types default. Gán chuỗi thô ném TypeError khi enforcement bật.
Điểm chính
- XSS = JS kẻ tấn công chạy trên origin bạn với quyền nạn nhân.
- Stored, reflected, DOM-based khác nhau ở chỗ data vào; tất cả cần xử lý output an toàn.
- Sink là trọng tâm phòng tránh.
- Ưu tiên textContent / auto-escape; sanitize bằng DOMPurify; escape đúng ngữ cảnh; validate URL.
- 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.
Tiếp theo
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.