
Most injection bugs are a string you concatenated into a command interpreter, not a failure of HTTPS.
SQL, OS shells, LDAP, and template engines all have the same shape. User input sits next to trusted syntax. The interpreter cannot tell which bytes were meant as data. A quote or a metacharacter closes your string and opens an instruction you did not write.
The usual mistake is escaping by hand, or blacklisting words like SELECT, and treating an ORM as automatic safety. Parameterized queries and allow-listed commands close the interpreter hole. Escaping is what you do when you cannot use those, and it is the weaker option.
This page is the sinks that still show up in production, the tests that prove the query is parameterized, and the CVEs that keep arriving when a framework leaves one path concatenated.
CVE-2026-24908 is a CVSS 10.0 in OpenEMR’s Patient REST API. The _sort query parameter was concatenated into ORDER BY. AISLE published the set on 28 April 2026 and said the product sits in front of more than 100,000 providers. Injection is still untrusted data reaching an interpreter while carrying structure the interpreter will honor.
The control is a typed boundary before the interpreter, an allowlist where a bind parameter cannot go, and a CI grep for the methods that turn the ORM back into a string builder.
Keep the secure coding checklist next to this page. Read XSS when the interpreter is HTML or JavaScript in the browser, and CSRF when the browser is the one attaching the cookie. This page is the server-side interpreters.
The same quote string meets the SQL engine twice. Glued into the statement it becomes grammar. Bound to $1 it stays a value.
SecureCoding
One bug, many interpreters
CWE-89 is SQL. CWE-78 is OS command. CWE-94 is code. CWE-117 is log. The shared shape is the same: a string or an object you did not mint is parsed as grammar by a system that was supposed to treat it as data.
That is why “we use an ORM” is not an answer, and why “we sanitize input” is usually the wrong layer. Sanitizing for SQL quotes does nothing when Mongo sees an operator object. Escaping a filename does nothing when the child binary reads a leading dash as a flag. The fix is: keep structure out of the untrusted value, or refuse the value.
Five interpreters show up in a typical Express app:
- SQL. Postgres, MySQL, SQLite. Bind values. Never bind identifiers.
- NoSQL. Mongo query objects. A nested object is an operator.
- The shell.
child_process.execruns/bin/sh -c. Metacharacters become a second command. - Templates. EJS, Pug, Handlebars, or a string you
evalinto HTML. User text is data. User text is not a template source. - Logs. A newline in a username splits one event into two. In 2026 that second line can also be a prompt to whatever reads the stream.
Prompt injection is the newest sibling. It is still this bug: untrusted text crossing into a model context that will treat it as instructions. This page does not walk that attack. It does treat LLM-written SQL the way you treat a first-week hire’s PR.
OpenEMR, April 2026: _sort became ORDER BY
AISLE’s 28 April 2026 post and Dark Reading’s 29 April writeup of the same set. CVE-2026-24908 is the Patient REST API _sort parameter concatenated into SQL ORDER BY with no allowlist. CVE-2026-23627 is a second SQL injection in the immunization module. OpenEMR 8.0.0 is the patched line.
That is the identifier gap this page already had. You cannot bind a column name. A REST _sort=created looks like a value. The engine treats it as grammar. The map in the identifier section is the control: user token to a hardcoded column, never the token into the SQL.
camdenreslink wrote on that HN thread: strong teams are not still concatenating user data into queries, not in 2026. The CVE says a widely deployed health record system still was. The grep later on this page is how you find the next _sort.
SQL in the ORM era: the escape hatches
Prisma Client, Sequelize, Knex, and TypeORM all parameterize the query builder path. The failure mode is the method you reach for when the builder cannot express the query. Those methods have names. They grep.
Prisma’s raw-queries markdown is the first-party list. Four methods, two of them labeled Unsafe in the page:
$queryRaw/$executeRaw: tagged template. Prisma builds a prepared statement. Variables are values.$queryRawUnsafe/$executeRawUnsafe: a raw string. The docs say these are “at significant risk of making your code vulnerable to SQL injection.”
The tagged-template path, which is the one you want:
// Prisma: tagged template. email is a bound value.
const rows = await prisma.$queryRaw`
SELECT id FROM "User" WHERE email = ${email}
`;
$queryRaw only accepts a template string or Prisma.sql. If you build a string and pass it in, you are no longer on that path. The hatch is the Unsafe pair. Leave it for the identifier case in the section below, and only after an allowlist.
Sequelize’s v7 raw-queries page is blunt about sequelize.query: a plain interpolated string is dangerous; the sql tag is the safe form. literal() embeds a fragment with no automatic escaping. Knex’s query builder documents whereRaw and fromRaw with ? for values and ?? for identifiers. TypeORM’s SQL-tag page wraps .query() and warns that a function wrapper around a template expression is inserted with no escaping, which is how you pass a table name you already allowlisted.
First-party pages: Prisma raw queries, Sequelize raw SQL, Knex query builder, TypeORM SQL tag.
The CI list at the bottom greps every one of those names. “We use an ORM so we’re fine” dies on the first hit.
Four hatches that turn the ORM back into a string
Same job, four names. Grep the right-hand column. The left-hand column is the path that still binds.
| Stack | Binds values | Grep this hatch |
|---|---|---|
| Prisma | $queryRaw tagged template | $queryRawUnsafe / $executeRawUnsafe |
| Sequelize | sql tag | sequelize.query on a glued string, literal() |
| Knex | builder + ? / ?? | whereRaw, fromRaw with a concatenated fragment |
| TypeORM | SQL tag | query() around a template that is not a tag |
NoSQL operator injection is a parser problem
Mongo does not concatenate SQL. It evaluates a query object. If a field you expected to be a string arrives as an object, operators such as $ne are grammar. MongoDB $ne operator page. The form is { field: { $ne: value } }. The old prevent-nosql-injection tutorial URL on mongodb.com 404s; that is the page I wanted and could not cite.
The object does not have to come from a handwritten JSON body. Express 4’s default query parser is qs in extended mode. qs turns bracket keys into nested objects before your handler runs. By the time you read req.query.user, the damage is already a JavaScript object.
JSON bodies are the other door. express.json() will give you a nested object on req.body whether you are on Express 4 or 5. Do not pass req.body into findOne. Pull the fields, require a string, then query.
function asString(value, name) {
if (typeof value !== "string") {
const err = new Error(name + " must be a string");
err.status = 400;
throw err;
}
return value;
}
// FIX: typed fields only. Never spread req.body into a filter.
const email = asString(req.body.email, "email");
const password = asString(req.body.password, "password");
const user = await users.findOne({ email });
// compare a hash. do not put password in the filter
A sanitizer that assigns back onto req.query is the wrong tool on Express 5. req.query is a getter. The cheap control is the type check above, plus leaving the query parser on simple unless you have a reason to turn qs back on.
The Express 5 upgrade quietly changed your query shape
Two first-party Express pages do not tell the same default. The Express 5 migration guide says req.query is no longer writable, and “the default query parser has been changed from ‘extended’ to ‘simple’.” The same guide sets express.urlencoded extended to false by default and requires Node 18. The Express 5 Request Object page still says the qs module “is used by default.” Confirm against your installed express if a test depends on nested query objects.
Simple is Node’s querystring. Bracket keys stay literal keys. Extended is qs. Bracket keys become objects. That is an injection-surface change hiding inside a major-version bump.
// Express 5 default. Do not assign to req.query; it is a getter.
app.get("/search", (req, res) => {
// GET /search?user[$ne]=x
// simple: { "user[$ne]": "x" }
// extended / qs: { user: { $ne: "x" } }
const user = req.query.user;
if (user !== undefined && typeof user !== "string") {
return res.status(400).send("bad query");
}
res.json({ shape: typeof user, keys: Object.keys(req.query) });
});
If a filter, a report, or a Mongo find still assumes nested query objects, the upgrade will look like a functional regression. If you turn extended back on to restore the old shape, you also restore the operator-injection shape. Say that out loud in the PR. Do not “fix” the parser by handing qs every query string and then forgetting the type check.
Bodies are unchanged. A JSON POST still arrives as whatever express.json() parsed. The Express 5 default does not save a login route that spreads req.body.
Three questions after the Express 5 bump
Does upgrading to Express 5 close NoSQL injection?
It closes the default query-string door. Simple mode will not turn bracket keys into operator objects. It does not close express.json() bodies, and it does not survive app.set("query parser", "extended"). Keep the string check.
Is Prisma $queryRaw safe?
The tagged-template form binds values. The docs still warn that string-building into that method reopens injection, and that identifiers cannot be bound. $queryRawUnsafe is the hatch. Grep it.
Is execFile enough to stop command injection?
It stops the shell. It does not stop a leading-dash argument from becoming a flag. Put -- before user paths, reject a name that starts with -, and never set shell: true to paper over Windows.
Command injection: exec, execFile, and argument injection
The Node.js v26.7.0 child_process page. exec “spawns a shell then executes the command within that shell.” The page says, in those words, never pass unsanitized user input to it. execFile “does not spawn a shell by default.” That is the first cut. It is not the last.
Argument injection is the leftover. A filename that starts with - is a flag to a lot of Unix tools. execFile("convert", [userPath]) has no shell, and it will still feed that path to ImageMagick as an option. The separator most of those tools honor is --. Validate the first character too, because not every binary is POSIX-polite.
const { execFile } = require("node:child_process");
const path = require("node:path");
function safeName(name) {
if (typeof name !== "string" || name.length === 0) return null;
if (name.startsWith("-")) return null;
const base = path.basename(name);
if (base !== name) return null;
return base;
}
// FIX: no shell. -- so a leading dash cannot become a flag.
function preview(userName, cb) {
const name = safeName(userName);
if (!name) return cb(new Error("bad name"));
execFile("convert", ["--", name, "preview.png"], { cwd: "/var/previews" }, cb);
}
Do not set shell: true on execFile or spawn to “make Windows work” and then pass user text through. The Node page repeats the same warning for that option. If you need a .bat on Windows, spawn cmd.exe with a fixed script path and an allowlisted argument list. Do not build a command line.
Template and log injection
Server-side template injection is compiling a string the user supplied, or interpolating it into a template source, so the engine treats it as code. The fix is boring: one template you wrote, data you pass in. Do not ejs.render(userText). Do not concatenate a username into an EJS file and then render that file.
// FIX: fixed template, data only. userText is never source.
res.render("profile", { displayName: userText });
Output encoding for HTML is an XSS job. This page stops at “do not let the user write the template.”
Log injection is a newline in a field you then interpolate into a line-oriented stream. One event becomes two. The second line can look like a timestamped error from a different user. In 2026 a lot of those streams feed an LLM-based triage queue or a SOC copilot. A crafted username is then a prompt. I am not citing a vendor rate for how often that happens. The control does not need a rate: log structured objects.
// FIX: one event, one object. no string join across user fields.
logger.info({ event: "login", userId, ip });
pino and winston both do this when you pass an object. console.log("login " + username) does not. If a downstream tool can only ingest lines, emit JSON lines and keep the user value inside a field.
Treat the model like a first-week hire
Svengali-tech wrote on Hacker News on 2 February 2026, launching a Semgrep wrapper: “The problem: AI-generated code often ships with hardcoded secrets, SQL injection vulnerabilities, and weak crypto.” I am quoting that sentence. I am not quoting a percentage. I did not find a first-party rate I trust, so I am not inventing one.
The training data is full of 'SELECT * FROM users WHERE id = ' + id. Models reproduce the median of what they saw. A tagged template or a query builder is the exception in that corpus. The practical response is process.
Treat the generated file like a first-week hire’s first PR:
- The author does not merge it.
- A human reads every diff that touches a query builder, a raw-SQL helper, or
child_process. - CI greps the hatch list. A new
$queryRawUnsafefails the check. - The model does not get to pick identifiers. Column names come from an allowlist in your repo.
That is the whole control. Do not wait for a vendor to publish a “generated-SQLi” percentage. The grep is free and it runs tonight.
Where parameterization cannot reach
Prisma’s own raw-queries page says template variables cannot be used for identifiers or SQL keywords. ORDER BY ${ordering} and FROM ${myTable} are documented as queries that do not work on $queryRaw. jsmith45 said the same thing on Hacker News in September 2022, from the other side of the API: you cannot bind a column name or an ASC/DESC token, and paginated tables need both.
The only correct answer is a map from a user token to a hardcoded identifier. Six lines. No Prisma.raw on the user string. No ?? on the user string. The user never touches the SQL.
const SORT = {
created: { col: "created_at", dir: "DESC" },
name: { col: "name", dir: "ASC" },
};
function orderBy(token) {
return SORT[token] || SORT.created;
}
// token is "created" or "name". the SQL sees only our literals.
const { col, dir } = orderBy(req.query.sort);
const rows = await knex("jobs").orderBy(col, dir);
If you truly need a raw identifier in Prisma, the docs send you to $queryRawUnsafe. That is the hatch. Interpolate only the allowlisted literal, and keep values on $queryRaw / Prisma.sql. Do not build the whole query as one string “just this once.”
Dynamic WHERE clauses are the other half of jsmith45’s comment. Compose fragments with the tagged-template helper (Prisma.sql, Sequelize sql, Knex ? bindings). Concatenating " AND " + clause is how the interpolation comes back.
Tests and greps you can run today
You are not walking an exploit. You are proving your parser returned a string, your hatch list is empty, and a state-changing helper does not call exec.
- On your own app, hit a GET route that reads
req.queryand returns the parsed shape (or log it). Use a bracket key. - Expect a string key under Express 5 simple. An object under
usermeans extended /qsis on. - POST a JSON body to your own login or search route where a field should be a string, but send an object. Expect 400. A 200 means the handler accepted a non-string.
- Grep the hatch list. A hit is a review.
# Probe your own search route. Do not send this at a foreign host.
curl -sS -G "https://your-app.example/search" \
--data-urlencode 'user[$ne]=x'
# Express 5 simple: look for a literal key user[$ne], value a string.
# Object under "user" means the extended parser is on.
# Your own JSON route. Expect 400 if the handler requires a string.
curl -sS -X POST "https://your-app.example/account/lookup" \
-H "Content-Type: application/json" \
--data '{"email":{"note":"not-a-string"}}'
# Expect: HTTP/2 400
The grep list. Run it in CI. Tune the TypeORM query( pattern so it does not match every HTTP handler; anchor on dataSource.query, manager.query, getRepository, or your wrapper name.
rg -n --glob '!node_modules/**' --glob '!dist/**' \
'\$queryRawUnsafe|\$executeRawUnsafe|Sequelize\.literal|\.literal\(|sequelize\.query\(|\.whereRaw\(|\.fromRaw\(|\.orderByRaw\(|dataSource\.query\(|manager\.query\(|\.queryRaw\('
rg -n --glob '!node_modules/**' \
'\bexec(Sync)?\(|shell:\s*true'
Then grep routers for app.get handlers that write to the database, the same way the CSRF page does. A GET that interpolates a query string into SQL is both an injection bug and a cacheable mutation.
What the internet thinks about injection
Two community discussions capture the recurring argument:
Hacker Newsjsmith45 ยท 1 Sep 2022
“Parameterizied queries do eliminate injection in the cases that they support, but it is not uncommon to need to so some level of query building manually, since not everything you might want to do is supported by parameterized queries. For example one cannot usually do “… order by ? ?”, passing in a column name and ascending/descending.”
The rest of that comment is the wish for an AST the engine would accept so a column name never goes back through a parser. The allowlist above is the version you can ship.
Database AdministratorsErwin Brandstetter ยท 12 Sep 2013
“These only take values when executed with EXECUTE. No SQL-injection possible at this stage. But you have to defend against SQL-injection while generating / concatenating the statement to be fed to PREPARE in the first place.”
On prepared queries versus Postgres functions. The hatch table above is that first-stage concatenate. Binding the value does not save a glued identifier.



