Cross-site scripting, XSS for short, is one of the vulnerabilities we report most often in web application pentests. The principle is old and well documented, yet it keeps turning up, including in applications built with modern frameworks. This article explains how XSS works, which three variants a pentester distinguishes, how we hunt for it and which measures address the problem at its root.
What cross-site scripting is
With XSS, an attacker manages to get their own JavaScript executed in someone else's browser, within the context of your application. That last part is what makes it dangerous. The script runs under the victim's session and can do anything that user can do: view data, submit forms, change settings. The cause is almost always the same: the application includes user input in a page without encoding it for the right context. The browser can then no longer tell data from code.
The three variants
Pentesters distinguish three kinds of XSS, and the distinction is more than academic: it determines who gets hit and how you find the vulnerability.
Reflected XSS is the simplest form. The payload travels in the request, for example in a search parameter, and the server bounces it back unencoded in the response. An attacker has to get a victim to click a crafted link. That limits the impact somewhat, although a convincing phishing email carrying such a link is quickly made.
Stored XSS is more serious. The payload is saved in the application, for example through a profile field, a comment, a support ticket or the filename of an upload, and executes for every user who views the affected page. No crafted link is needed. A single injection can hit hundreds of users, and if an administrator opens the page, the script runs with administrator privileges.
DOM-based XSS plays out entirely in the browser. Client-side JavaScript reads input, for example from the URL fragment, and places it in the page unsafely. The server sometimes never even sees the payload, which keeps this variant invisible to detection that only watches server traffic.
What an attacker can do with it
The classic demonstration with a pop-up window undersells the severity of XSS. A realistic attacker uses a successful injection to steal session tokens or other sensitive data from the page, to perform actions on behalf of the victim, or to overlay a fake login screen on top of the real page and harvest passwords. Stored XSS in an administrative environment can end in a full takeover of the application. Reading keystrokes within the affected page is also possible. In short: someone who can inject scripts into your application does not even need to reach your database to do serious damage.
How we test for XSS
During a pentest we first map every place where user input reappears in a page: parameters, form fields, HTTP headers, uploaded filenames and stored content. We then test, per location, which context the input lands in, because the payload depends on it. Input that ends up in an HTML attribute requires a different approach than input placed inside a piece of JavaScript or a URL. Automated scanners catch part of this, but the interesting cases require manual work: payloads that only execute after a second step, filters that block almost everything, and encoding that is correct in one context but not in another.
For stored XSS in administrative environments we use blind XSS techniques: a payload that only signals once it executes somewhere else, for example in an internal dashboard where support tickets are read. That is how we find injections whose effect remains invisible to the tester. We always work with harmless proof-of-concept payloads: the report demonstrates that execution is possible, without any user data having been accessed during the test.
Prevention: encoding first, then the safety nets
The structural fix for XSS is output encoding that matches the context the input lands in. HTML context requires HTML encoding, attribute context requires attribute encoding, JavaScript context needs different treatment again. Modern frameworks do this well by default: stay within the template syntax of React or Angular and you get encoding for free. The vulnerabilities arise at the exceptions, such as dangerouslySetInnerHTML in React or bypassing the sanitizer in Angular, and in everything that generates HTML outside the framework: email templates, PDF exports, older application components.
If users must be able to enter formatted text, for example in an editor with bold and italics, encoding alone is not enough. Then you need sanitization based on a strict allowlist of permitted elements and attributes, using a proven library such as DOMPurify. Home-built filters that strip suspicious characters fail in nearly every pentest; there is always an encoding or a variant the filter did not anticipate.
Beyond that, there are safety nets that limit the impact when something slips through anyway. A strict Content Security Policy can prevent injected scripts from executing or sending data out. The HttpOnly attribute on session cookies keeps those cookies out of reach of JavaScript. These measures do not fix the vulnerability, and they do not need to: they are the second line, not a replacement for encoding and sanitization.
Remediation and retest
Fixing XSS findings is usually not a large project: the right encoding or sanitization at the reported location, plus a check whether the same pattern occurs elsewhere in the code. That last step is the one that tends to be skipped. A pentester reports the locations found within the test window; the same unsafe construction often exists in more places. After remediation comes a retest, in which we verify that the original payloads no longer work and that the fix leaves no detours open. A filter that blocks the reported payload but lets a variant through will surface during a retest.
Frequently asked questions
Is XSS still relevant now that modern frameworks escape output automatically?+
Yes. Frameworks such as React and Angular escape by default, but they offer escape hatches such as dangerouslySetInnerHTML with which developers deliberately switch that protection off, for example to render formatted text. Older application components, email templates and PDF generators also fall outside framework protection. We still find XSS regularly in pentests.
What is the difference between reflected, stored and DOM-based XSS?+
With reflected XSS the script travels in the request and the server bounces it straight back in the response. With stored XSS the script is saved, for example in a profile field or support ticket, and executes for everyone who later views the page. With DOM-based XSS the problem arises entirely in the browser, because client-side JavaScript handles input unsafely.
Does a Content Security Policy solve XSS?+
No. A CSP is a second line of defence that limits the impact of a successful injection. The vulnerability itself is fixed with correct output encoding and sanitization. A strict CSP is still valuable: if encoding is forgotten somewhere, the policy can prevent the injected script from actually executing.
How does a pentester demonstrate XSS without causing damage?+
With a harmless proof-of-concept payload that only demonstrates that script execution is possible, such as a notification or a controlled callback request to a system owned by the tester. The report then describes what a real attacker could have done with the same injection, without that being carried out during the test.
Need help with security?
How secure is your IT environment really? We test it with vulnerability scans and pentests, and guide the implementation of ISO 27001 and IEC 62443.
Explore SecurityAbout the author
Partner | IT Auditor