Security review checklist for AI-generated JavaScript
No compiler, a dynamic object model and the largest dependency ecosystem in software. The vulnerabilities that recur in generated JS follow from all three.
Generated JavaScript is usually better than the average tutorial it learned from — it parameterises queries, it hashes passwords properly. What survives is a specific set of failures rooted in the language's dynamism and in Node's defaults.
Turn on the machine checks first#
npm i -D eslint eslint-plugin-security @microsoft/eslint-plugin-sdl
npm audit --omit=devType-aware ESLint is the bigger win. no-floating-promises is a correctness rule but it is also a security one: an unhandled rejection in an auth path can leave a request half-processed. See the failure-mode catalogue for the config.
The language-specific one: prototype pollution#
JavaScript's inheritance model creates a vulnerability class that does not exist elsewhere.
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") merge(target[key] ??= {}, source[key]);
else target[key] = source[key];
}
return target;
}
merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'));
({}).isAdmin; // true — every object in the process now has itGenerated deep-merge, config-loading and query-parsing helpers produce this constantly, because the naive recursive merge is what appears everywhere.
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
function merge(target, source) {
for (const key of Object.keys(source)) {
if (BLOCKED.has(key)) continue; // the whole fix
if (source[key] && typeof source[key] === "object") {
merge((target[key] ??= Object.create(null)), source[key]);
} else {
target[key] = source[key];
}
}
return target;
}Three defences worth knowing: skip the dangerous keys, use Object.create(null) for anything holding untrusted keys, and prefer Map over a plain object for user-controlled lookups. Object.freeze(Object.prototype) at startup is a blunt but effective backstop.
Catch it with: eslint-plugin-security's detect-object-injection (noisy but finds it), and a grep for for (const k in in merge-like functions.
Injection#
Code execution#
eval(userInput);
new Function(`return ${expr}`)();
setTimeout("doThing()", 100); // string form is eval
vm.runInNewContext(code); // NOT a security boundaryvm is the trap people get wrong: it is for isolation of trusted code, not a sandbox. Untrusted code needs a separate process with isolated-vm or a real container.
Command execution#
exec(`git log --author=${author}`); // shell, injectable
execFile("git", ["log", `--author=${author}`]); // no shellNode's exec runs through a shell; execFile and spawn do not. Generated code reaches for exec because template literals read nicely.
SQL and NoSQL#
db.query(`SELECT * FROM users WHERE email = '${email}'`); // parameteriseAnd the Mongo-specific one, which generated code produces routinely:
User.findOne({ email: req.body.email, password: req.body.password });
// client sends {"password": {"$gt": ""}} and matches any userValidate that query values are primitives before they reach the driver, or use a schema at the boundary.
XSS#
<div dangerouslySetInnerHTML={{ __html: comment }} /> // stored XSS
element.innerHTML = userContent;
document.write(location.hash);React escapes by default, which is why the only XSS in most React apps is dangerouslySetInnerHTML, href={userUrl} (a javascript: URL), and ref callbacks writing raw HTML.
// if you must render user HTML
import DOMPurify from "dompurify";
el.innerHTML = DOMPurify.sanitize(html);
// and validate URLs
const u = new URL(userUrl, base);
if (!["http:", "https:"].includes(u.protocol)) throw new Error("bad scheme");Catch it with: react/no-danger, and grep -rn 'innerHTML\|dangerouslySetInnerHTML\|document.write'.
ReDoS#
A generated regex with nested quantifiers, applied to input you do not control, blocks the entire event loop — so in Node a single request takes down the process for every user.
/^(\s*\w+)*$/.test(input) // catastrophic backtracking
/^(a+)+$/.test(input)This is worse in Node than in a threaded runtime: one bad request means total unavailability, not one slow response.
Correct: flatten the pattern, anchor it, bound the input length before matching, and prefer a parser for anything structured. Catch it with: eslint-plugin-security's detect-unsafe-regex, and redos-detector in CI for generated patterns.
Node-specific#
Path traversal#
app.get("/files/:name", (req, res) => {
res.sendFile(path.join(DIR, req.params.name)); // ../../etc/passwd
});const full = path.resolve(DIR, req.params.name);
if (!full.startsWith(DIR + path.sep)) return res.sendStatus(403);The + path.sep matters — without it /var/data-evil passes a prefix check against /var/data.
SSRF#
const r = await fetch(req.query.url); // fetches your cloud metadata endpointAllowlist scheme and host, resolve the hostname and reject private ranges, and disable redirects — an attacker controls a public host that redirects to 169.254.169.254.
const r = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });Unbounded input#
app.use(express.json()); // default limit is small but check it
const body = await new Response(req).text(); // no limit at allSet an explicit body size limit, and use AbortSignal.timeout() on every outbound request. An unbounded JSON.parse on a large payload is also an event-loop block, so this is a denial-of-service issue as well as a memory one.
Auth and secrets#
JWT handling#
jwt.verify(token, secret); // fine
jwt.decode(token); // does NOT verify. ever.
jwt.verify(token, secret, { algorithms: undefined }); // allows "none" in old libsAlways pass an explicit algorithms allowlist. And storing a JWT in localStorage makes it readable by any XSS — an httpOnly, Secure, SameSite cookie is the safer default.
Timing-unsafe comparison#
if (token === expected) { } // early return leaks length and prefix
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); // requires equal lengthsSecrets in logs#
logger.info("calling %s", url, { headers }); // headers include Authorization
console.log("user", user); // user includes passwordHashThe second one is the common case: an object logged wholesale that happens to carry a secret field. Redact at the logger, not at each call site.
Dependencies#
npm's install scripts make this the largest supply-chain surface of any ecosystem — a compromised transitive package runs code on your machine at install time. That has its own page: dependency hygiene.
The review, as commands#
git diff | grep -nE "eval\(|new Function|innerHTML|dangerouslySetInnerHTML|document\.write"
git diff | grep -nE "\bexec\(|child_process|vm\.run"
git diff | grep -niE "api_key|secret|token|password|authorization"
git diff | grep -nE "jwt\.decode|localStorage\.setItem\(.token|verify=false"
npx eslint . && npm audit --omit=devThirty seconds. What is left for human attention is the two things no tool finds: missing authorisation checks and SSRF, because both need knowing what your system is meant to allow.
Authorisation is worth the one test per resource type:
test("cannot read another user's invoice", async () => {
const res = await request(app).get(`/invoices/${bobsInvoice.id}`).set(aliceAuth);
expect(res.status).toBe(404); // 404, not 403 — do not confirm it exists
});The JavaScript-specific ones to remember
Prototype pollution in any merge or deep-assign helper, and ReDoS in any generated regex applied to untrusted input. Both are language-specific, both are routinely generated, and the second one takes your whole process down rather than one request.
Common questions#
Is prototype pollution still a real risk?#
Yes. Modern frameworks have hardened their own paths, but the vulnerability lives in application code — the deep-merge helper, the config loader, the query-string parser someone wrote. Generated code produces the vulnerable pattern because the naive recursive merge is what dominates the training data.
Does TypeScript prevent any of this?#
Some of it — typed boundaries make it harder to pass an unexpected shape — but types are erased at runtime, so an attacker sending __proto__ is unaffected by your type annotations. See the TypeScript security page for what types do and do not buy you.
Why is ReDoS worse in Node than elsewhere?#
Because Node is single-threaded. A catastrophic regex in a threaded server makes one request slow; in Node it blocks the event loop, so every concurrent user is affected until it finishes. That turns a performance bug into total unavailability.
What is the highest-value check for a web app?#
The authorisation test above, one per resource-returning endpoint. No linter finds a missing ownership check, it is the most common real vulnerability in generated web code, and the test is a single line.
Get the JavaScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.