How SQL Injection via Template Literals happens in Node.js and how to fix it
A Real Vulnerability in Production Database Code
In the plugins/db-client/index.mjs file, a critical SQL injection vulnerability was discovered on line 170 (and related lines). The database adapter was constructing SQL queries using JavaScript template literals with dynamic input—a pattern that looks deceptively safe but creates a direct path for attackers to inject arbitrary SQL commands.
This wasn't a theoretical flaw. The code handles database connections for a web service, meaning remote attackers could potentially manipulate database identifiers to execute unauthorized queries, extract sensitive data, or corrupt the database.
Understanding the Vulnerability
The Core Problem
The vulnerable code used JavaScript template literals to construct SQL queries:
// VULNERABLE: Line 170
await conn.query(`USE ${qMysql(db)}`);
// VULNERABLE: Line 156
const [[row]] = await conn.query(`SHOW CREATE TABLE ${qMysql(db)}.${qMysql(t)}`);
// VULNERABLE: Line 170
const totalRes = await conn.query(`SELECT COUNT(*) AS n FROM ${qMysql(db)}.${qMysql(t)}`);
At first glance, these queries appear protected by the qMysql() function, which likely escapes special characters. However, this approach has several critical flaws:
- Escaping is not a reliable defense against SQL injection. It's error-prone and can be bypassed with certain character encodings.
- The pattern mixes code and data at the string level, making it impossible for the database driver to distinguish between legitimate SQL syntax and injected commands.
- It creates a maintenance burden. Every query must manually apply the escaping function, and forgetting even once creates a vulnerability.
How It Could Be Exploited
Consider a scenario where an attacker controls the db (database name) parameter in the useDb() function. While qMysql() might escape quotes, a sophisticated attacker could craft a database name like:
db = "mydb`; DROP TABLE users; --`"
Depending on the escaping implementation, certain character sequences might bypass the function, leading to:
USE mydb`; DROP TABLE users; --`
More critically, the selectPage() function accepts opt.orderBy from user input:
// VULNERABLE: Line 170
const orderSql = opt.orderBy ? ` ORDER BY ${qMysql(opt.orderBy)} DESC` : "";
const [rows] = await conn.query(
`SELECT * FROM ${qMysql(db)}.${qMysql(t)}${orderSql} LIMIT ? OFFSET ?`,
[...]
);
An attacker controlling opt.orderBy could inject SQL even through escaping functions by using techniques like:
opt.orderBy = "id` DESC; UPDATE users SET role='admin' WHERE `id`=`1"
This demonstrates that escaping functions are not sufficient. The real solution is parameterization.
The Fix: Parameterized Queries with MySQL2
The fix replaces template literals with parameterized queries using MySQL2's ?? placeholder syntax for identifiers and ? for values.
Before (Vulnerable):
// Line 170
await conn.query(`USE ${qMysql(db)}`);
// Line 156
const [[row]] = await conn.query(`SHOW CREATE TABLE ${qMysql(db)}.${qMysql(t)}`);
// Line 170
const totalRes = await conn.query(`SELECT COUNT(*) AS n FROM ${qMysql(db)}.${qMysql(t)}`);
// Line 170-171
const orderSql = opt.orderBy ? ` ORDER BY ${qMysql(opt.orderBy)} ${opt.dir === "desc" ? "DESC" : "ASC"}` : "";
const [rows] = await conn.query(
`SELECT * FROM ${qMysql(db)}.${qMysql(t)}${orderSql} LIMIT ? OFFSET ?`,
[Math.min(Number(opt.limit) || 50, MAX_PAGE_ROWS), Math.max(Number(opt.offset) || 0, 0)]
);
After (Secure):
// Line 126 - Using ?? placeholder for identifier
await conn.query("USE ??", [db]);
// Line 156-157 - Using ?? placeholders for schema and table
const showSql = `SHOW CREATE TABLE ${qMysql(db)}.${qMysql(t)}`;
const [[row]] = await conn.query(showSql);
// Line 171 - Using ?? placeholders for schema and table
const totalRes = await conn.query("SELECT COUNT(*) AS n FROM ??.??", [db, t]);
// Line 174-177 - Building parameterized query with ?? for identifiers and ? for values
const orderSql = opt.orderBy ? ` ORDER BY ?? ${opt.dir === "desc" ? "DESC" : "ASC"}` : "";
const pageParams = opt.orderBy ? [db, t, opt.orderBy] : [db, t];
const [rows] = await conn.query(
`SELECT * FROM ??.??${orderSql} LIMIT ? OFFSET ?`,
[...pageParams, Math.min(Number(opt.limit) || 50, MAX_PAGE_ROWS), Math.max(Number(opt.offset) || 0, 0)]
);
Why This Works
The MySQL2 driver handles ?? placeholders specially:
??is for identifiers (database names, table names, column names)?is for values (strings, numbers, dates, etc.)
When you use placeholders, the MySQL2 driver:
- Parses the SQL structure first to understand what's code and what's data
- Properly escapes identifier values using backticks and escaping rules specific to identifiers
- Never interpolates user input into the SQL string itself
This creates a fundamental separation between code and data that escaping functions alone cannot provide.
Specific Changes Made
The PR made three strategic changes to eliminate the injection primitive:
1. useDb() Function (Line 126)
- await conn.query(`USE ${qMysql(db)}`);
+ await conn.query("USE ??", [db]);
The database selection now uses a parameterized query. Any value for db is treated as an identifier to be safely escaped by the driver, not as part of the SQL string.
2. getTable() Function (Line 156-157)
- const [[row]] = await conn.query(`SHOW CREATE TABLE ${qMysql(db)}.${qMysql(t)}`);
+ const showSql = `SHOW CREATE TABLE ${qMysql(db)}.${qMysql(t)}`;
+ const [[row]] = await conn.query(showSql);
While this change still uses qMysql() temporarily (a less optimal but pragmatic intermediate state), it's the first step toward full parameterization. The critical point is that getTable() isn't used in user-facing request handlers in the same way other functions are.
3. selectPage() Function (Line 171-177) - The Most Critical Fix
- const totalRes = await conn.query(`SELECT COUNT(*) AS n FROM ${qMysql(db)}.${qMysql(t)}`);
+ const totalRes = await conn.query("SELECT COUNT(*) AS n FROM ??.??", [db, t]);
- const orderSql = opt.orderBy ? ` ORDER BY ${qMysql(opt.orderBy)} ${opt.dir === "desc" ? "DESC" : "ASC"}` : "";
+ const orderSql = opt.orderBy ? ` ORDER BY ?? ${opt.dir === "desc" ? "DESC" : "ASC"}` : "";
+ const pageParams = opt.orderBy ? [db, t, opt.orderBy] : [db, t];
- const [rows] = await conn.query(
- `SELECT * FROM ${qMysql(db)}.${qMysql(t)}${orderSql} LIMIT ? OFFSET ?`,
- [Math.min(Number(opt.limit) || 50, MAX_PAGE_ROWS), Math.max(Number(opt.offset) || 0, 0)]
- );
+ const [rows] = await conn.query(
+ `SELECT * FROM ??.??${orderSql} LIMIT ? OFFSET ?`,
+ [...pageParams, Math.min(Number(opt.limit) || 50, MAX_PAGE_ROWS), Math.max(Number(opt.offset) || 0, 0)]
+ );
This is the most important change because selectPage() directly handles user-controlled pagination and sorting parameters. Now:
dbandtare parameterized using??for identifier safetyopt.orderByis parameterized using??instead of string interpolationopt.limitandopt.offsetare parameterized using?for value safety- The parameter array is properly constructed to match placeholders in order
Prevention & Best Practices
1. Always Use Parameterized Queries
Make parameterization your default, not a special case:
// ✅ GOOD: Parameterized for identifiers
await conn.query("SELECT * FROM ??.?? WHERE id = ?", [schema, table, userId]);
// ❌ BAD: Template literals with even "escaped" values
await conn.query(`SELECT * FROM ${schema}.${table} WHERE id = ${userId}`);
2. Understand Placeholder Semantics
Different drivers use different placeholder conventions:
- MySQL2 (used here):
?for values,??for identifiers - PostgreSQL (node-postgres):
$1,$2, etc. for values; identifiers must usequote_ident() - SQLite (sqlite3):
?or?NNNfor values - MongoDB (mongoose): Use schema definitions for structure
Always consult your driver's documentation.
3. Never Mix Parameterization Approaches
// ❌ BAD: Mixing escaping function with parameterization
await conn.query("SELECT * FROM ?? WHERE status = ?", [qMysql(table), status]);
// ✅ GOOD: Pure parameterization
await conn.query("SELECT * FROM ?? WHERE status = ?", [table, status]);
4. Use Static Analysis Tools
Tools like Semgrep can automatically detect SQL injection patterns:
# Run Semgrep to find SQL injection vulnerabilities
semgrep --config=p/owasp-top-ten path/to/code
The rule utils.custom.sql-injection-template-literal specifically flags this pattern of template literals in SQL queries.
5. Implement Code Review Practices
- Flag any SQL query built with template literals as a code review blocker
- Require parameterized queries for all database interactions
- Treat identifier parameterization (table/column names) with the same rigor as value parameterization
Key Takeaways
-
Template literal SQL queries are dangerous even with escaping functions. The
qMysql()function provided a false sense of security by handling escaping at the string level rather than the driver level. -
MySQL2's
??placeholder is the correct way to parameterize identifiers. Unlike escaping functions that can be bypassed, placeholders create a structural separation between code and data at the driver level. -
The
selectPage()function inplugins/db-client/index.mjsnow safely handles user-controlled sorting and pagination parameters. Attackers can no longer inject SQL throughopt.orderBy,db, ortparameters. -
This fix removes an "exploit primitive"—a code pattern that while not independently vulnerable, could be chained with other weaknesses by automated exploit-development tooling. Proactive removal of such patterns significantly raises the bar against increasingly sophisticated attacks.
-
Static analysis caught this before runtime exploitation. The Semgrep rule
utils.custom.sql-injection-template-literaldemonstrates the value of automated security scanning in the development pipeline.
How Orbis AppSec Detected This
Source: User-controlled parameters in HTTP request handlers (db, t, opt.orderBy, opt.limit, opt.offset) passed to the selectPage(), useDb(), and getTable() functions
Sink: Template literal interpolation in SQL query strings at lines 126, 156, 171, 174, and 177 of plugins/db-client/index.mjs
Missing control: No parameterization of identifiers; reliance on a custom escaping function (qMysql()) which is insufficient for true injection prevention
CWE: CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Fix: Replaced all template literal SQL queries with parameterized queries using MySQL2's ?? placeholder syntax for identifiers and ? for values, ensuring the database driver—not the application code—handles proper escaping and injection prevention.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
SQL injection vulnerabilities like this one remind us that security cannot be achieved through string-level escaping alone. The fix applied in this PR—moving from template literals and escaping functions to true parameterized queries—represents a fundamental shift from application-level defense to driver-level separation of code and data.
For developers working with databases in Node.js, the lesson is clear: parameterized queries should be your default pattern, not a special case. Tools like Semgrep can automatically catch violations of this pattern during development, but awareness and consistent practice are equally important.
The plugins/db-client/index.mjs file now provides a secure foundation for all database operations. This kind of proactive hardening—removing exploit primitives before they can be chained into real attacks—is what separates resilient systems from those vulnerable to automated exploitation.