Web Security & Ethical Hacking · Lesson 5 of 23

XSS — Cross-Site Scripting Prevention

What XSS Actually Does to Your Users

Cross-Site Scripting (XSS) lets an attacker run arbitrary JavaScript in a victim's browser in the context of your site. That means:

  • Session theft — steal the access token from localStorage or a non-HttpOnly cookie
  • Keylogging — capture every keystroke on the page, including passwords
  • Credential harvesting — replace the login form with a fake one that phones home
  • Redirects — send users to phishing pages
  • Cryptomining — run code in the background using the user's CPU

It's consistently in the OWASP Top 10 because the surface area is enormous — any place user-controlled data appears in a web page is a potential injection point.

Type 1 — Stored XSS

The attacker stores malicious content in your database. Every user who views that content executes the script.

Attack scenario: a comment field on a blog post.

Attacker submits comment:
"Great article! "

If the server renders this comment without encoding it, every visitor to that post runs the script. The script sends their cookies to the attacker's server.

This is the most dangerous type because it's persistent and affects all users, not just one.

Type 2 — Reflected XSS

The malicious script is in the request itself — usually in a URL parameter — and the server reflects it back in the response.

Attack scenario: a search page that displays "Results for: ".

https://shop.example.com/search?q=

The attacker sends this URL to victims (via email, social media). When clicked, the server reflects the query parameter into the page and the script executes.

Reflected XSS requires the victim to click a crafted link, making it less scalable than stored XSS but still effective for targeted attacks.

Type 3 — DOM-Based XSS

The vulnerability is entirely in client-side JavaScript. The server never sees the payload.

Attack scenario: a page that reads window.location.hash and writes it to the DOM.

JAVASCRIPT
// Vulnerable code
document.getElementById('welcome').innerHTML = 'Hello, ' + location.hash.slice(1);

Payload URL:

https://app.example.com/welcome#

The hash is not sent to the server, so server-side defenses don't help. This is caught only by client-side analysis.

Content Security Policy — The Primary Defense

CSP is an HTTP header that tells the browser which sources of content are trusted. Even if an attacker injects a <script> tag, the browser refuses to run it if it violates the policy.

C#
// ASP.NET Core middleware
app.Use(async (context, next) =>
{
    context.Response.Headers.Add(
        "Content-Security-Policy",
        "default-src 'self'; " +
        "script-src 'self' https://cdn.example.com; " +
        "style-src 'self' 'unsafe-inline'; " +
        "img-src 'self' data: https:; " +
        "connect-src 'self' https://api.example.com; " +
        "frame-ancestors 'none'; " +
        "base-uri 'self';"
    );
    await next();
});

What each directive does:

  • default-src 'self' — by default, only load from the same origin
  • script-src 'self' — scripts only from same origin (blocks inline scripts and eval)
  • frame-ancestors 'none' — also prevents clickjacking
  • base-uri 'self' — prevents base tag injection attacks

Avoid unsafe-inline for scripts — it defeats most of the protection. Use nonces instead (covered below).

Start with Content-Security-Policy-Report-Only to audit without blocking.

Output Encoding — The Foundation

CSP is defense in depth. The primary fix is never inserting untrusted data into HTML without encoding it first.

Different contexts require different encoding:

HTML body context — encode &, <, >, ", ':

C#
// Razor does this automatically with @variable
// Manual:
var safe = HtmlEncoder.Default.Encode(userInput);

HTML attribute context — same HTML encoding, ensure value is in quotes:

HTML
<!-- Safe -->
<input value="@Model.UserInput" />

<!-- Unsafe  attribute injection possible without quotes -->
<input value=@Model.UserInput />

JavaScript context — use JSON encoding, never string concatenation:

HTML
<!-- Unsafe  attacker closes the string and injects code -->
<script>var name = '@userInput';</script>

<!-- Safe  use data attributes or server-side JSON -->
<div id="app" data-user='@JsonSerializer.Serialize(user)'></div>
<script>
  const user = JSON.parse(document.getElementById('app').dataset.user);
</script>

URL context — URL-encode user data in query parameters:

C#
var safeUrl = $"/search?q={Uri.EscapeDataString(userQuery)}";

React and Angular's Auto-Escaping

React escapes all values rendered with JSX by default:

JSX
// This is safe — React HTML-encodes the value
function Comment({ text }) {
  return <p>{text}</p>;
}
// Even if text = "<script>alert(1)</script>", it renders as literal text

Angular similarly escapes interpolated values ({{ }}).

But both frameworks have escape hatches that bypass this protection.

dangerouslySetInnerHTML — Handle with Extreme Care

JSX
// DANGEROUS — this executes scripts
function RenderHTML({ content }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

// If you MUST render user HTML (rich text editor output), sanitize first
import DOMPurify from 'dompurify';

function SafeRenderHTML({ content }) {
  const clean = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
    ALLOWED_ATTR: ['href', 'target']
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

DOMPurify parses the HTML and removes anything not in your allowlist. It's maintained, well-tested, and the standard choice for client-side sanitization.

Also sanitize on the server before storing — never trust that the client-side sanitization ran.

HttpOnly Cookies — Blocking Session Theft

The most impactful thing you can do for session security: put your tokens in HttpOnly cookies.

C#
// Set token in HttpOnly cookie
Response.Cookies.Append("access_token", token, new CookieOptions
{
    HttpOnly = true,    // JavaScript cannot read this
    Secure = true,      // Only sent over HTTPS
    SameSite = SameSiteMode.Strict,
    Expires = DateTimeOffset.UtcNow.AddMinutes(15)
});

HttpOnly means document.cookie cannot access the cookie. Even if an attacker runs arbitrary JS on your page, they cannot steal this token. They can still make requests using the cookie (CSRF — addressed in the CSRF article), but they can't exfiltrate the token itself.

Combined with SameSite=Strict, you eliminate both XSS token theft and CSRF in most scenarios.

CSP Nonces for Inline Scripts

If you have legitimate inline scripts (e.g., injected by SSR), use nonces instead of unsafe-inline:

C#
// Middleware generates a unique nonce per request
app.Use(async (context, next) =>
{
    var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
    context.Items["CspNonce"] = nonce;

    context.Response.Headers.Add(
        "Content-Security-Policy",
        $"script-src 'self' 'nonce-{nonce}';"
    );
    await next();
});

// Razor — inject nonce into inline scripts
@{
    var nonce = Context.Items["CspNonce"] as string;
}
<script nonce="@nonce">
    window.__INITIAL_DATA__ = @Json.Serialize(Model.InitialData);
</script>

The browser only executes inline scripts that have the matching nonce. The attacker's injected <script> has no nonce and is blocked.

Defense Summary

| Threat | Defense | |--------|---------| | Stored/Reflected XSS | Output encoding, parameterized queries | | DOM-based XSS | Avoid innerHTML, use textContent | | Script injection | CSP with script-src 'self' and nonces | | Session theft via XSS | HttpOnly + Secure + SameSite cookies | | User HTML (rich text) | DOMPurify with strict allowlist |

No single defense is sufficient. Layer them: encode output, add CSP, use HttpOnly cookies, sanitize user HTML. A breach through one layer should still be stopped by another.