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.
// 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.
// 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 originscript-src 'self'— scripts only from same origin (blocks inline scripts and eval)frame-ancestors 'none'— also prevents clickjackingbase-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 &, <, >, ", ':
// Razor does this automatically with @variable
// Manual:
var safe = HtmlEncoder.Default.Encode(userInput);HTML attribute context — same HTML encoding, ensure value is in quotes:
<!-- Safe -->
<input value="@Model.UserInput" />
<!-- Unsafe — attribute injection possible without quotes -->
<input value=@Model.UserInput />JavaScript context — use JSON encoding, never string concatenation:
<!-- 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:
var safeUrl = $"/search?q={Uri.EscapeDataString(userQuery)}";React and Angular's Auto-Escaping
React escapes all values rendered with JSX by default:
// 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 textAngular similarly escapes interpolated values ({{ }}).
But both frameworks have escape hatches that bypass this protection.
dangerouslySetInnerHTML — Handle with Extreme Care
// 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.
// 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:
// 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.