Cross-Site Scripting (XSS)
What Cross-Site Scripting Is
Cross-Site Scripting (XSS, CWE-79) is a vulnerability that lets an attacker inject and execute malicious JavaScript in someone else's browser. Unlike SQL injection, which attacks the server, XSS attacks the users of the application. The victim's browser runs the attacker's script as if it were a legitimate part of the site, with all the privileges of that origin: access to the DOM, to unprotected cookies, to local storage, and to any API the user's session has enabled.
The root cause is the same family as injection: the application takes untrusted data from the user and places it into an HTML page without properly handling it. When the browser interprets that page, it cannot distinguish between the developer's legitimate HTML and the injected malicious code; to the rendering engine, both are simply markup. The problem appears whenever a piece of data crosses a context boundary —from plain text into HTML, into an attribute, into a <script> block, or into a URL— without the transformation that context requires. XSS belongs to the injection category of the OWASP Top 10 and remains one of the most common and persistent vulnerabilities on the web.
Reflected XSS
Reflected XSS is the simplest variant. It happens when data sent in a request, usually through a URL parameter or a form, is returned immediately in the response without encoding. For example, a search page that displays "No results found for: [term]" and places the term directly into the HTML.
The attacker crafts a specially designed URL containing the script and sends it to the victim by email, messaging, or a link on another site. When the victim clicks, the server reflects the content in the response and the browser executes it. Because the attack lives in a link and is not stored, it requires social engineering for the victim to trigger the URL, but it is still very dangerous because it runs in the context of the trusted site, inheriting its origin and permissions.
Stored and DOM-Based XSS
Stored XSS (persistent) is the most serious. Here the malicious code is saved on the server —for example in a comment, a user profile, or a forum message— and is served to everyone who views that content. A single injection can affect thousands of users with no need for social engineering, since the script is delivered automatically every time the compromised page loads.
DOM-based XSS is different: the vulnerability lives entirely in the client-side JavaScript code. It occurs when a script on the page takes data from an attacker-controllable source (a source such as location.hash or location.search) and writes it into a dangerous DOM sink, such as innerHTML, document.write, or eval. In this case the server may never see the malicious payload —the fragment after # is not even sent to the server—, which makes it harder to detect with traditional server-side tools.
The Impact of XSS
Once an attacker can execute JavaScript in the victim's browser, the possibilities are broad:
- Session theft: reading session cookies (if they are not protected with
HttpOnly) or tokens from local storage to hijack the account. - Keylogging: recording keystrokes to capture credentials, cards, or messages.
- In-page phishing: modifying the DOM to insert fake login forms that look legitimate because they live on the real domain.
- Actions on behalf of the victim: issuing authenticated requests using the user's permissions, bypassing many anti-CSRF defenses.
- Admin compromise: an XSS aimed at an administration panel can escalate to full control of the application.
XSS can also be used to spread self-replicating web worms: a stored script that, when it runs in a victim's session, posts a copy of itself to their profile, infecting every user who visits it. Historical incidents on social networks showed that this pattern can reach hundreds of thousands of accounts within hours. The impact therefore ranges from the theft of individual data to the mass compromise of a platform.
Fix: DOM-Based XSS
The starting point is a very common pattern: taking a value from the URL and dumping it into the DOM with innerHTML. When you assign a string to innerHTML, the browser interprets it as HTML and will run any active markup it contains.
// VULNERABLE: userInput comes from the URL and is interpreted as HTML
const userInput = new URLSearchParams(location.search).get("q");
document.getElementById("output").innerHTML = userInput;
The fix is to stop treating the data as HTML. When we only need to display text, textContent inserts it as a literal string: the browser never interprets it as markup.
// SAFE: textContent treats the value as text, not as HTML
const userInput = new URLSearchParams(location.search).get("q");
document.getElementById("output").textContent = userInput;
If the real requirement is to render HTML submitted by the user (for example, a rich-text editor), encoding is not enough: you must sanitize with a vetted library that strips dangerous markup before inserting it.
// SAFE: sanitize limited-trust HTML with DOMPurify
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userInput);
document.getElementById("output").innerHTML = clean;
Fix: Reflected XSS on the Server
On the server, the classic mistake is concatenating user input into the HTML of the response. The value crosses the boundary into an HTML context with no transformation.
// VULNERABLE: input concatenated directly into the response HTML
app.get("/search", (req, res) => {
const term = req.query.q;
res.send(`<h1>Results for: ${term}</h1>`);
});
The solution is to apply contextual output encoding or, better still, delegate to a template engine with auto-escaping (Nunjucks, EJS with escaping, Handlebars, etc.), which encodes variables according to context by default.
// SAFE: template engine with auto-escaping via res.render
app.get("/search", (req, res) => {
res.render("search", { term: req.query.q });
});
// In the template, {{ term }} is automatically encoded for the HTML context
Modern front-end frameworks help a great deal: React and Angular encode by default the values interpolated into JSX or templates. The risk reappears when you use the deliberate escape hatches, such as dangerouslySetInnerHTML in React or [innerHTML]/bypassSecurityTrust* in Angular. Those APIs must be reserved for content already sanitized with DOMPurify.
Defense in Depth
No single measure is sufficient; combine several layers:
- Contextual output encoding: encode according to the exact context where the data lands —HTML, HTML attribute, JavaScript, URL, and CSS—, because each has a different set of dangerous characters. A value that is safe inside a
<p>may be exploitable inside anhrefor a<script>block. - Frameworks that auto-escape: prefer React, Angular, Vue, or template engines with escaping on by default, and treat every use of
dangerouslySetInnerHTML/innerHTMLas an exception that demands justification. - Sanitization with a vetted library: when you must accept user HTML, pass it through DOMPurify or another maintained library; never write your own filter with regular expressions.
HttpOnlycookies: mark session cookies asHttpOnly(plusSecure+SameSite) so that an XSS cannot read them from JavaScript, reducing the impact of session theft.- Content-Security-Policy (CSP): an HTTP header that tells the browser which origins it may load and execute resources from, blocking the execution of injected inline scripts. A restrictive CSP acts as a safety net even when an encoding flaw exists:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Avoid 'unsafe-inline' and 'unsafe-eval' in script-src; for the inline scripts you genuinely need, use a per-request nonce instead of loosening the policy.
Detection Tools
Verify your defenses with active and automated testing:
- Burp Suite and OWASP ZAP: intercepting proxies that help identify points where input is reflected without encoding and check CSP behavior.
- DAST scanners (Dynamic Application Security Testing): crawl the running application looking for reflected and stored injection points; integrate them into the CI pipeline to catch regressions.
- Static analysis and linters: rules that flag uses of
innerHTML,dangerouslySetInnerHTML, orevalin the code, so they can be reviewed before merge.
Prevention Checklist
- [ ] Encode all untrusted output according to its context (HTML, attribute, JS, URL, CSS).
- [ ] Use frameworks or template engines with auto-escaping enabled by default.
- [ ] Prefer
textContentoverinnerHTMLwhen only displaying text. - [ ] Sanitize any HTML from untrusted sources with DOMPurify; never filter with your own regex.
- [ ] Treat
dangerouslySetInnerHTMLandinnerHTMLas justified, reviewed exceptions. - [ ] Mark session cookies as
HttpOnly,Secure, andSameSite. - [ ] Deploy a restrictive Content-Security-Policy, without
'unsafe-inline'or'unsafe-eval'. - [ ] Validate input on the server as an additional layer, not as the only defense.
- [ ] Test with Burp Suite, OWASP ZAP, and DAST scanners in CI.