Skip to content

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: and https: 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

<div class="comment"><%- comment.body %></div>

<%- writes raw HTML. <%= escapes.

Vulnerable — HTML built by concatenation

res.send(`<h1>Results for ${req.query.q}</h1>`)

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

<div class="comment"><%= comment.body %></div>

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 %>"), and style — HTML escaping is not enough in any of these.
  • javascript:, data: and vbscript: URLs in href, src, formaction; filtering only javascript: misses java\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 decodeURIComponent can 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 no Content-Type, so a JSON response is sniffed as HTML.
  • CSP weakened by unsafe-inline or a permissive script-src.

What to grep for

  • <%-, {{{, dangerouslySetInnerHTML, v-html
  • innerHTML, insertAdjacentHTML, document.write, outerHTML
  • res.send( with backticks, res.write(, Content-Type
  • href="<%, src="<%, JSON.stringify inside <script>

Resources