Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix cross-site scripting in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Print every field through EJS's escaping tag <%= %>, which turns & < > " and ' into entities, and never through the raw tag <%- %>. For a link, escaping is not enough: parse the URL with URL.parse and link only http and https, because <%= %> leaves javascript:alert(1) working.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0 with EJS 6.0.1 templates
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks8 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Find every <%- %> tag and every res.send/res.write that builds HTML from req.query, req.params, req.body or stored records; <%- %> and string concatenation do no escaping.
  2. Switch each one to <%= %> so EJS escapes & < > " and ', and keep attribute values in quotes so an escaped quote cannot start a new attribute.
  3. For a value that is a URL, do not rely on escaping: parse it with URL.parse(value) and render the link only when the protocol is http: or https:; otherwise drop the link or point it at '#'.
  4. For rich text that must keep some HTML, sanitize it with DOMPurify (allowlist based) rather than a regex, and only then mark it safe.
  5. Add regression tests that render this card's payloads and parse the HTML, asserting no script element, no on* attribute and no non-http(s) link, next to functional tests for <, & and quotes.
  6. Send a Content-Security-Policy header as defence in depth, and keep database error text and stack traces off the response.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableviews/profile/vulnerable.ejsHTML
<img src="/avatar.svg" alt="<%- name %>" width="64" height="64">
<h1><%- name %></h1>
<p class="bio"><%- bio %></p>

With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).

The fix: output encoding

Fixedviews/profile/fixed.ejsHTML
<img src="/avatar.svg" alt="<%= name %>" width="64" height="64">
<h1><%= name %></h1>
<p class="bio"><%= bio %></p>

With IMPL=fixed, 9 of 9 tests pass.

The fix: URL scheme allowlist

Fixedviews/profile/fixed.ejsHTML
<%
  // Escaping cannot make a URL safe: javascript:alert(1) has nothing to escape.
  // Parse it the way browsers do and link only to http and https.
  const url = URL.parse(website);
  const href = url && (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : null;
-%>
<% if (href) { -%>
    <a href="<%= href %>">Website</a>
<% } -%>

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixviews/profile/wrong.ejsHTML
<%
  // Print raw so entities stored in old profiles (&eacute;) still render,
  // but escape < and > so no tag can be injected.
  const escapeTags = (text) => text.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
-%>
    <img src="/avatar.svg" alt="<%- escapeTags(name) %>" width="64" height="64">
    <h1><%- escapeTags(name) %></h1>
    <p class="bio"><%- escapeTags(bio) %></p>

With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/profile.test.jsJavaScript
// What a browser would run from the page: script elements, event-handler
// attributes, and links whose URL, parsed the way browsers parse it, is not
// http or https.
function assertNothingExecutable(elements) {
  assert.deepEqual(elements.filter((e) => e.tagName === 'script').map(text), [], 'script element');
  const handlers = elements.flatMap((e) => e.attrs.filter((a) => /^on/i.test(a.name)).map((a) => `${e.tagName} ${a.name}`));
  assert.deepEqual(handlers, [], 'event-handler attribute');
  for (const href of links(elements)) {
    const { protocol } = new URL(href, 'https://profiles.example/');
    assert.ok(protocol === 'https:' || protocol === 'http:', `link to a ${protocol} URL: ${JSON.stringify(href)}`);
  }
}

describe(`exploit (IMPL=${impl})`, () => {
  test('a script element in the bio does not run', async () => {
    assertNothingExecutable(await profile({ name: 'Mallory', bio: '<script>alert(document.domain)</script>' }));
  });

  test('an event-handler attribute in the bio does not run', async () => {
    assertNothingExecutable(await profile({ name: 'Mallory', bio: '<img src=x onerror=alert(document.domain)>' }));
  });

  test('a quote in the name cannot break out of the alt attribute', async () => {
    const name = '" onerror="alert(document.domain)';
    const page = await profile({ name });
    assertNothingExecutable(page);
    assert.equal(avatarAlt(page), name);
  });

  test('a javascript: website is not linked', async () => {
    assertNothingExecutable(await profile({ name: 'Mallory', website: 'javascript:alert(document.domain)' }));
  });

  test('a tab inside the scheme does not smuggle a javascript: URL through', async () => {
    // Browsers delete tabs and newlines from a URL before parsing it.
    assertNothingExecutable(await profile({ name: 'Mallory', website: 'java\tscript:alert(document.domain)' }));
  });
});

Pitfalls

  • <%- %> is the EJS footgun: it exists for HTML you produced, never for user input. <%= %> is the default and escapes & < > " and '.
  • HTML-escaping a URL does not disarm it. javascript:alert(1) has no HTML metacharacters, so <%= website %> renders a working javascript: link. URLs need a scheme allowlist, not escaping.
  • A blocklist such as startsWith('javascript:') misses leading/compound whitespace, mixed case (JaVaScRiPt:), tabs or newlines inside the scheme (java&#9;script:), and other dangerous schemes (data:, vbscript:). Allowlist http/https/mailto instead.
  • Unquoted attributes defeat escaping: <img alt=<%= name %>> lets a space add an event handler even though the value was escaped. Always quote.
  • React and other client frameworks have the same hatch: dangerouslySetInnerHTML, Angular bypassSecurityTrustHtml, Vue v-html. Sanitize with DOMPurify before using them.
  • Express 5's default 'simple' query parser returns an array for ?name=a&name=b; the app checks typeof value === 'string' so a repeated field is a 400 rather than an unescaped [object Object].
  • Setting Content-Type to text/plain or adding X-Content-Type-Options: nosniff changes how a browser treats the body, but it is not a substitute for encoding in an HTML response.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
  2. Run semgrep --config r/javascript.express.security.audit.xss.ejs.explicit-unescape views; expect findings in vulnerable.ejs and wrong.ejs only.
  3. Against your own endpoint, send name="><script>alert(1)</script> and website=javascript:alert(1) and confirm the script is shown as text and the link is dropped.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix cross-site scripting in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.express.security.audit.xss.ejs.explicit-unescape.template-explicit-unescapeFlagged the three <%- %> raw outputs in views/profile/vulnerable.ejs and the three in views/profile/wrong.ejs; no findings in fixed.ejs, which uses <%= %>. This regex rule is the main automated signal for this stack.Checked: Semgrep 1.180.0 (CE)
Semgrepjavascript.express.security.audit.xss.ejs.var-in-href.var-in-hrefFlagged the <a href="<% ... %>"> line in all three templates, including fixed.ejs. The rule is a regex that cannot see the scheme allowlist that makes the fixed code safe, so the fixed hit is a false positive to review and suppress, much like Bandit on an allowlisted ORDER BY in the SQL card.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/reflected-xssExpected to model this card: semmle/javascript/frameworks/Templating.qll understands EJS delimiters and which interpolation escapes (getEscapingInterpolationRegexp), so a request value reaching a <%- %> placeholder is a sink. js/stored-xss is the stored-data companion.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0 with EJS 6.0.1 templates
express5.3.0
ejs6.0.1
parse5 (test only)8.0.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/node-express runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Toolchain
  • node 26.11.1
  • express 5.3.0
  • ejs 6.0.1
  • parse5 8.0.1
  • semgrep 1.180.0
Matrix
fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. EJS: tags (<%= %> escaped, <%- %> unescaped)
  3. Node.js v26 URL.parse() (returns null instead of throwing)
  4. DOMPurify (HTML sanitizer for rich text)
  5. React: dangerouslySetInnerHTML
  6. WHATWG URL Standard: URL parsing (scheme, tab and newline removal)
  7. Express 5: Migrating to Express 5 (req.query and the default query parser)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.