Back to Blog
high SEVERITY8 min read

How SQL Injection via Template Literals happens in Node.js and how to fix it

A high-severity SQL injection vulnerability was discovered in the `plugins/db-client/index.mjs` file where database queries were constructed using JavaScript template literals with dynamic input. The fix replaces vulnerable string interpolation with parameterized queries using MySQL2's `??` placeholder syntax, eliminating the injection vector entirely.

O
By Orbis AppSec
Published September 9, 2026Reviewed September 9, 2026

Answer Summary

SQL injection via template literals is a CWE-89 vulnerability in Node.js MySQL clients where unsanitized database identifiers (table names, column names) are directly interpolated into SQL strings using backticks. The fix replaces template literal concatenation with parameterized queries using MySQL2's `??` identifier placeholder, which properly escapes identifiers while maintaining type safety and separation of code from data.

Vulnerability at a Glance

cweCWE-89 (SQL Injection)
fixUse MySQL2's `??` placeholder syntax for identifier parameterization instead of template literal interpolation
riskAttackers could inject arbitrary SQL commands by manipulating database, table, or column names passed to query functions
languageJavaScript (Node.js)
root causeDynamic database identifiers concatenated directly into SQL strings without parameterization
vulnerabilitySQL Injection via Template Literals

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:

  1. Escaping is not a reliable defense against SQL injection. It's error-prone and can be bypassed with certain character encodings.
  2. 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.
  3. 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:

  1. Parses the SQL structure first to understand what's code and what's data
  2. Properly escapes identifier values using backticks and escaping rules specific to identifiers
  3. 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:

  • db and t are parameterized using ?? for identifier safety
  • opt.orderBy is parameterized using ?? instead of string interpolation
  • opt.limit and opt.offset are 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 use quote_ident()
  • SQLite (sqlite3): ? or ?NNN for 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 in plugins/db-client/index.mjs now safely handles user-controlled sorting and pagination parameters. Attackers can no longer inject SQL through opt.orderBy, db, or t parameters.

  • 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-literal demonstrates 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.

References

Frequently Asked Questions

What is SQL injection via template literals?

It occurs when JavaScript template literals (`${variable}`) are used to construct SQL queries with dynamic input, allowing attackers to inject malicious SQL by controlling variable values passed to the query function.

How do you prevent SQL injection in Node.js MySQL applications?

Use parameterized queries with the `?` placeholder for values and `??` placeholder for identifiers (table/column names). Never use template literals or string concatenation to build SQL queries with dynamic input.

What CWE is SQL injection via template literals?

CWE-89 (Improper Neutralization of Special Elements used in an SQL Command - 'SQL Injection'), and more specifically, it represents a variant where escaping functions like `qMysql()` are bypassed by using template literals.

Is using an escaping function like qMysql() enough to prevent SQL injection?

No, as this vulnerability demonstrates. Escaping functions can be inconsistently applied or bypassed. Parameterized queries are the gold standard because they maintain separation between code and data at the database driver level.

Can static analysis detect SQL injection via template literals?

Yes. Semgrep, the tool that caught this vulnerability with the rule `utils.custom.sql-injection-template-literal`, detects patterns where template literals are used to construct SQL strings with dynamic content.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #97

Related Articles

high

How Python SQLAlchemy Raw Query SQL Injection happens and how to fix it

A high-severity SQL injection vulnerability was fixed in the `skills/last30days/scripts/store.py` file where untrusted input was being concatenated directly into raw SQL queries. The fix replaces string concatenation with SQLAlchemy's TextualSQL prepared statements using named parameters, preventing attackers from manipulating database queries through malicious input.

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

critical

How SQL Injection happens in PHP bulk email systems and how to fix it

A critical SQL injection vulnerability in `admin/utilities/bulkEmailSystem.php` allowed attackers to inject arbitrary SQL through unvalidated database names passed from user input. The fix implements strict input validation using regex pattern matching to ensure only safe database identifiers are processed, preventing exploitation of the bulk email functionality.

high

How SQL Injection Happens in Shell Scripts and How to Fix It

A critical SQL injection vulnerability in the `check_kuota.sh` script allowed attackers to execute arbitrary SQL commands by controlling the USERNAME parameter. The fix implements strict input validation using regex pattern matching to ensure only legitimate username characters are accepted, eliminating the injection vector entirely.

critical

How SQL Injection happens in JavaScript template literals and how to fix it

A critical SQL injection vulnerability in `index.js` allowed attackers to execute arbitrary database commands by manipulating block IDs passed through the UI. The fix implements strict input validation using a regex whitelist before any SQL construction, eliminating the injection vector while preserving functionality.

high

How Denial of Service via Crafted ZIP File happens in Node.js and how to fix it

CVE-2026-39244 is a high-severity denial of service vulnerability in the adm-zip npm package that allows attackers to crash Node.js applications by uploading maliciously crafted ZIP files. The fix upgrades adm-zip from version 0.5.16 to 0.6.0, which adds proper memory bounds checking to prevent excessive allocation during archive extraction.