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.
- 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.
- Switch each one to <%= %> so EJS escapes & < > " and ', and keep attribute values in quotes so an escaped quote cannot start a new attribute.
- 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 '#'.
- For rich text that must keep some HTML, sanitize it with DOMPurify (allowlist based) rather than a regex, and only then mark it safe.
- 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.
- Send a Content-Security-Policy header as defence in depth, and keep database error text and stack traces off the response.
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
<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
<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
<%
// 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
<%
// Print raw so entities stored in old profiles (é) still render,
// but escape < and > so no tag can be injected.
const escapeTags = (text) => text.replaceAll('<', '<').replaceAll('>', '>');
-%>
<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 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.
// 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)' }));
});
});- <%- %> 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	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.
- 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).
- Run semgrep --config r/javascript.express.security.audit.xss.ejs.explicit-unescape views; expect findings in vulnerable.ejs and wrong.ejs only.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.audit.xss.ejs.explicit-unescape.template-explicit-unescape | Flagged 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) |
| Semgrep | javascript.express.security.audit.xss.ejs.var-in-href.var-in-href | Flagged 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) |
| CodeQL | js/reflected-xss | Expected 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 |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 with EJS 6.0.1 templates |
| express | 5.3.0 |
| ejs | 6.0.1 |
| parse5 (test only) | 8.0.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/node-expressfolder.
npm ci# 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 --silentEach 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.
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
- OWASP Cross Site Scripting Prevention Cheat Sheet
- EJS: tags (<%= %> escaped, <%- %> unescaped)
- Node.js v26 URL.parse() (returns null instead of throwing)
- DOMPurify (HTML sanitizer for rich text)
- React: dangerouslySetInnerHTML
- WHATWG URL Standard: URL parsing (scheme, tab and newline removal)
- Express 5: Migrating to Express 5 (req.query and the default query parser)
Attack patterns: