
JavaScript’s common vulnerabilities are XSS in the browser, injection and prototype pollution on the server, and a dependency that changed overnight.
MITRE still ranks XSS first. npm still sees malicious versions of popular packages. Your checklist has to include both the sink in your code and the install in CI.
The usual mistake is a DOMPurify ticket and no lockfile review, or the reverse.
This page is the JavaScript-specific shapes and the links to the deeper pages for each.
MITRE’s 2025 CWE Top 25 still ranks CWE-79 first. chalk@5.6.1 landed on npm on 8 September 2025. chalk issue 656 and sindresorhus’s note that the bad versions were up for about two hours.
The rest of this page maps those three classes and then stops. Read the XSS guide for Trusted Types, CSP nonces, and setHTML. Read the Helmet guide when the job is a header pack. Read the Express 5 backend page for SSRF, eval, and leaky 500s. Read the lockfile versus install-time malware split when the row is a surprise publish.
Three classes, not a language ranking
The 2021 copy leaned on a Stack Overflow survey share and a most-vulnerable-languages list. I am not repeating either figure. I could not find a first-party 2025 or 2026 report that restates those two percentages in a way I can cite, so they stay off this page. The useful split is the sink, the merge, and the tree you install.
- CWE-79, the HTML or script sink. Untrusted text is parsed as the user’s page. Stored, reflected, and DOM labels are delivery. The write is
innerHTML,v-html,dangerouslySetInnerHTML, or a concatenated template. - CWE-1321, the prototype write. A key the client chose lands on
Object.prototype. Laterif (user.isAdmin)that skippedObject.hasOwnreads an inherited value. Express 5.2.1 still hands you a plainreq.body. - The install tree. A known advisory in
package-lock.jsonis homework: bump the pin. A hijacked publish with no advisory yet is a different clock. September 2025 was the second clock.
SQL concatenation is real in Node. It is not a JavaScript-only class. Put it on the injection page. Cookie flags shrink document.cookie. They do not close a script that already runs in your origin. This page will not re-teach session cookies.
XSS is the sink, not the survey
Firefox 148 shipped on 24 February 2026 with element.setHTML(). The release notes and the Mozilla Hacks writeup name Trusted Types and the HTML Sanitizer API. MDN’s setHTML page says the method parses markup, drops XSS-unsafe tags and handlers, then inserts the rest. That is safer than innerHTML. It is still HTML. A username field wants textContent.
renderComment is the named helper. A comment preview that must stay text uses it. A CMS body that must accept a subset of tags belongs on the XSS page, behind a policy you named, not behind a 2016 filter package.
function renderComment(el, body) {
el.textContent = body;
}
function renderCmsBody(el, trustedHtml) {
// named fallback: only after a policy minted TrustedHTML
el.innerHTML = trustedHtml;
}
Call renderComment from the comment route. Call renderCmsBody only with a TrustedHTML value from a policy that sanitizes and refuses script sinks. A string you concatenated is not that value. Default interpolation in React, Vue, and Angular already encodes. The hatch is the remaining write. Grep the hatch, not the framework name.
MDN marks Trusted Types Baseline 2026, newly available across current browsers since February 2026. A CSP of require-trusted-types-for 'script' makes the DOM sinks reject a plain string. You still write the policy. The browser does not invent encoding for you. Keep that policy on the XSS page. This page only needs the rule: names and labels go through renderComment.
rg -n "innerHTML|dangerouslySetInnerHTML|v-html|insertAdjacentHTML|document\\.write|xss-filters" \
--glob '!node_modules'
Pollution is a merge you wrote
CWE-1321 is assigning through a key the client chose. JSON.parse in modern Node does not set the instance prototype from a __proto__ key. The next function that copies that key with bracket assignment does. Object.assign(settings, req.body) and _.merge({}, req.body) are the usual writers. Express 5.2.1, current on npm as of August 2026, does not wrap that copy.
mergeOwn is the named helper. It copies own keys. It skips the three strings that turn a map into a prototype write. Use Object.create(null) when the keys are data, not a class.
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
function mergeOwn(target, src) {
if (src === null || typeof src !== "object" || Array.isArray(src)) {
return target;
}
for (const key of Object.keys(src)) {
if (BLOCKED.has(key)) continue;
target[key] = src[key];
}
return target;
}
function emptyMap() {
return Object.create(null);
}
Object.keys already skips inherited names. The blocked set is leftover for a null-prototype source that smuggles those strings. Do not recurse into every nested object unless you call mergeOwn on each level and still refuse the blocked names. A settings object that started as a class instance should not be the merge target at all. Copy onto emptyMap(), then read with Object.hasOwn.
Node’s --disable-proto=throw makes the accessor throw. CLI docs. It is a process belt on a host you control. It does not replace mergeOwn. Pair it only after you have tested libraries that still write the built-ins. The longer SSRF and eval story lives on the Express 5 backend page this article already linked. This page stops at the merge.
A lockfile CVE is not a hijack
That is hygiene. The 2026 split is two clocks. A public advisory against a pinned version is a bump. A maintainer account that published a new tarball on 8 September 2025 was a live tree with nothing for npm audit to match for those two hours.
pinAndAudit is the named pair: install from the lockfile you committed, then fail CI on a known advisory in that same tree. A floating npm install on the build agent is how a surprise patch version arrives.
// package.json scripts. identifiers stay pinAndAudit
{
"scripts": {
"pinAndAudit": "npm ci && npm audit --audit-level=high"
}
}
# CI on the app repo you maintain
npm run pinAndAudit
# Expect: exit 0, or a ticket on the advisory id, not a silent continue
Ignore scripts in CI until a package earns them. npm ci --ignore-scripts is the conservative default for a lockfile you already reviewed. Turn scripts back on for a named list, not for the whole registry. A CVE scanner stays quiet while a fresh malicious version has no advisory. That silence is why the lockfile page exists. This page only needs the install line and the audit line.
The live voice on this page is entuno on names. The lockfile page carries the install-time malware clock.
| Signal | What you do |
|---|---|
| Advisory in the lockfile | Bump, then pinAndAudit |
| Unexpected version bump | Diff the tarball, do not merge on the version alone |
| HttpOnly session cookie | Still fix the XSS sink. The script can ride the session |
| Helmet default CSP | Write directives. The stock header is a starter |
INPUT comment body, req.body, lockfile SINK renderComment -> textContent renderCmsBody only with TrustedHTML MERGE mergeOwn onto emptyMap BLOCKED skips proto keys TREE npm ci from committed lock pinAndAudit fails on a known advisory DEAD xss-filters 1.2.7 12 Sep 2016
Helmet sets headers. It does not encode
helmet 8.3.0 published on 12 July 2026. As of the current docs, the Helmet docs. app.use(helmet()) sets a pack of response headers, including a stock Content-Security-Policy. The stock policy is not a nonce you minted on this response. It does not rewrite res.send. It does not call renderComment. Mount it, then write the real directives on the Helmet page.
const helmet = require("helmet");
const express = require("express");
const app = express();
app.disable("x-powered-by");
app.use(helmet());
app.use(express.json({ limit: "32kb" }));
Cap the JSON body. A large merge is a memory bug before it is a pollution bug. Express 5’s express.json takes limit. If you also parse urlencoded bodies, set the same cap. Two parsers on one route is how a large form bypasses the JSON limit you thought you set.
A nonce minted once at process start is the other miss the Helmet page names: every HTML response shares it, and a CDN will cache it. Do not copy that pattern here. This page only mounts the pack so the three classes have a header belt. The sink is still renderComment.
Prove renderComment, mergeOwn, and pinAndAudit
You are not walking a stranger’s site. You are proving renderComment did not parse tags, mergeOwn ignored a blocked key, and pinAndAudit ran against the lockfile you ship.
- In a page you own, set a comment fixture to the characters
<b>x</b>. Expect the source to show those characters inside a text node. A<b>element means the route called a markup sink. - Unit-test
mergeOwnwith a source that includesconstructor. Expect the output to keep your name and to lack an ownconstructor. - In CI, run
npm run pinAndAudit. A known high advisory is a fail. An abandoned package is a ticket, not a shrug. - Grep the hatches this page already named. A hit on
xss-filtersor oninnerHTMLnext to request text is a review.
const assert = require("node:assert/strict");
const { JSDOM } = require("jsdom");
const dom = new JSDOM("<div id='c'></div>");
const el = dom.window.document.getElementById("c");
renderComment(el, "<b>x</b>");
assert.equal(el.textContent, "<b>x</b>");
assert.equal(el.children.length, 0);
const out = mergeOwn({ name: "ada" }, { constructor: { name: "nope" } });
assert.equal(out.name, "ada");
assert.equal(Object.hasOwn(out, "constructor"), false);
For the tree, keep the lockfile in git and refuse a pipeline that runs npm install without the lock. The audit line fails closed on a high advisory. A hijack with no advisory will not fail that line. Review the version diff. That is the honest limit of pinAndAudit.
Then dump one HTML response you already serve and look for Content-Security-Policy. Expect a header. Do not expect the header to prove renderComment ran. Those are different jobs.
Questions we keep getting
Does setHTML replace textContent for names and labels?
No. Firefox 148’s method still parses HTML and keeps safe tags. A display name is text. Use renderComment. Keep setHTML for a body that must be markup, and read the XSS page before you turn that on.
Does Express 5 block prototype pollution?
No. Current Express still gives you a plain object for req.body. The merge you write is the control. Use mergeOwn or do not merge. The backend page has the longer SSRF and eval story.
Is npm audit enough after September 2025?
It is enough for a published advisory against a pin you already have. It is silent on a fresh malicious tarball. Keep npm ci. Review unexpected bumps. That second clock lives on the lockfile page.



