Cross-site scripting (XSS)¶
When is it relevant¶
A change that puts data into an HTML, attribute, URL or script context where users or third parties can influence that data. This covers server-rendered HTML and templates, frontend code (innerHTML, dangerouslySetInnerHTML, v-html, href or src from data), and rendering of rich text, Markdown or SVG.
Attack¶
The attacker stores or reflects a payload, such as <img src=x onerror=…> or a javascript: link, that the browser runs as code in the victim's session. It can read data, act as the victim or take over their account. The context matters: escaping for HTML text doesn't protect attributes, URLs or script blocks.
Mitigation¶
- Render data with auto-escaping templates or
textContent, never string-built HTML. - When rich text is needed, sanitize it with an allowlist sanitizer such as DOMPurify.
- Allow only
http:andhttps:in links and sources built from data. - Serve APIs as
application/json, and add a Content Security Policy as defense in depth.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Escape by default in the template | Server-rendered HTML | EJS |
| Sanitise, then insert as HTML | Rich text that must keep markup | DOMPurify |
| Return data, render in the client | JSON APIs with a JS front end | OWASP XSS |
| Content Security Policy | Defence in depth, never the only control | Helmet |
Vulnerable — unescaped output in EJS
<%- writes raw HTML. <%= escapes.
Vulnerable — HTML built by concatenation
Vulnerable — escaped, but in the wrong context
<a href="../<%= profile.website %>">Website</a>
<script>const user = <%- JSON.stringify(user) %>;</script>
HTML escaping does not make a URL safe (javascript:alert(1) survives it), and
JSON.stringify output inside <script> can be broken out of with </script>.
Mitigated — escape by default
Docs: EJS tags
Mitigated — sanitise rich text
const window = new JSDOM('').window
const purify = DOMPurify(window)
const safeHtml = purify.sanitize(req.body.body, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code'],
ALLOWED_ATTR: ['href'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#)/i,
})
await Comment.create({ body: safeHtml, authorId: req.user.id })
Sanitise on output, or on input and output; storing sanitised HTML alone means a later change to the allowlist can't be applied retroactively.
Docs: DOMPurify configuration · jsdom
Mitigated — safe URL and safe script data
function safeUrl(value) {
try {
const url = new URL(value, 'https://example.com')
return ['http:', 'https:'].includes(url.protocol) ? url.href : '#'
} catch { return '#' }
}
<a href="../<%= safeUrl(profile.website) %>">Website</a>
<script type="application/json" id="bootstrap"><%- safeJson(user) %></script>
<script>const user = JSON.parse(document.getElementById('bootstrap').textContent)</script>
where safeJson replaces <, >, &, U+2028 and U+2029 with escapes.
Bypasses to check
- One
<%-left among many<%=, or a helper that returns a pre-built HTML string which the template then prints escaped-looking but raw. - Attribute context without quotes (
class=<%= x %>), event handlers (onclick="<%= x %>"), andstyle— HTML escaping is not enough in any of these. javascript:,data:andvbscript:URLs inhref,src,formaction; filtering onlyjavascript:missesjava\tscript:and case variants.- Client-side sinks:
innerHTML,outerHTML,insertAdjacentHTML,document.write,eval,location =,dangerouslySetInnerHTML. - Check then change: input sanitised, then decoded, unescaped, truncated or
concatenated afterwards — truncation can cut a tag open, and a second
decodeURIComponentcan revive an entity. - Stored XSS through fields nobody thinks of as HTML: filenames, user agents, error messages, PDF or email templates.
res.send()with a user-controlled string and noContent-Type, so a JSON response is sniffed as HTML.- CSP weakened by
unsafe-inlineor a permissivescript-src.
What to grep for
<%-,{{{,dangerouslySetInnerHTML,v-htmlinnerHTML,insertAdjacentHTML,document.write,outerHTMLres.send(with backticks,res.write(,Content-Typehref="<%,src="<%,JSON.stringifyinside<script>