Back to Blog
high SEVERITY6 min read

How SQL Injection Happens in Node.js Template Literals and How to Fix It

A high-severity SQL injection vulnerability was discovered in the `StateLedger` class's `readEvents()` method in `ts/src/state-ledger.ts`. The code constructed SQL queries using JavaScript template literals with dynamic input, creating an exploit primitive that could be chained with other weaknesses. The fix replaces template-based query construction with parameterized queries using SQLite's `?` placeholders.

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

Answer Summary

SQL injection via template literal construction in Node.js/TypeScript (CWE-89). The `StateLedger.readEvents()` method in `ts/src/state-ledger.ts` built queries using JavaScript template literals with dynamic `kind` parameter interpolation, creating a vulnerability where malicious input could alter query structure. Fixed by replacing template literal construction with SQLite parameterized queries using `?` placeholders and passing values via `.all(tenant, kind, limit)`, ensuring user input is never interpreted as SQL syntax.

Vulnerability at a Glance

cweCWE-89 (Improper Neutralization of Special Elements in an SQL Command)
fixReplace template literal interpolation with parameterized queries using `?` placeholders
riskDatabase manipulation, unauthorized data access, potential privilege escalation
languageTypeScript/Node.js (SQLite via better-sqlite3)
root causeDynamic SQL query construction using JavaScript template literals with unsanitized input
vulnerabilitySQL Injection via Template Literal Construction

Introduction

In a Node.js library handling event ledger operations, we discovered a high-severity SQL injection vulnerability in ts/src/state-ledger.ts at line 208. The StateLedger class's readEvents() method was constructing SQL queries using JavaScript template literals with dynamic input—a pattern that creates what security researchers call an exploit primitive. While not independently exploitable in the current codebase, this pattern could be chained with other weaknesses by automated exploit-development tooling to achieve full SQL injection attacks.

The vulnerable code lived in a critical data access method that retrieves event records for multi-tenant applications. Any downstream consumer using this package with modified input validation or in unexpected deployment contexts could face serious security risks.

The Vulnerability Explained

The Problematic Code Pattern

Before the fix, the readEvents() method used this dangerous pattern:

readEvents(kind?: string, limit = 100): LedgerEventRow[] {
  const where = kind ? 'WHERE tenant_id = ? AND kind = ?' : 'WHERE tenant_id = ?'
  const rows = this.db.prepare(
    `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
     FROM events ${where} ORDER BY seq DESC LIMIT ?`,
  ).all(...(kind ? [this.tenant, kind, limit] : [this.tenant, limit])) as LedgerEventRow[]
  return rows
}

The critical issue: The ${where} template literal interpolation injects raw SQL fragments directly into the query string. While this specific implementation uses ? placeholders for some values, the query structure itself is dynamically assembled via string concatenation.

How Exploitation Could Occur

The kind parameter flows into the where clause construction:

const where = kind ? 'WHERE tenant_id = ? AND kind = ?' : 'WHERE tenant_id = ?'

If an attacker could influence the logical path or if future code modifications introduced additional template interpolations, they could manipulate the SQL structure. Consider a hypothetical future developer adding:

// DANGEROUS: If this pattern were introduced
const where = kind ? `WHERE tenant_id = ? AND kind = '${kind}'` : 'WHERE tenant_id = ?'

This would create immediate SQL injection. The existing pattern establishes a precedent and infrastructure for unsafe query construction that automated tools can identify and exploit through variant analysis.

The purge() Method: A Related Concern

The PR also hardened purge() at line 674, where dynamic IN clause construction required careful handling:

// Before: Template literal for table name (if introduced) would be dangerous
// The fix ensures only VALUES are parameterized, not structure
const placeholders = s.kinds.map(() => '?').join(',')

The Fix

Before and After: readEvents() Method

Before (vulnerable):

readEvents(kind?: string, limit = 100): LedgerEventRow[] {
  const where = kind ? 'WHERE tenant_id = ? AND kind = ?' : 'WHERE tenant_id = ?'
  const rows = this.db.prepare(
    `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
     FROM events ${where} ORDER BY seq DESC LIMIT ?`,
  ).all(...(kind ? [this.tenant, kind, limit] : [this.tenant, limit])) as LedgerEventRow[]
  return rows
}

After (hardened):

readEvents(kind?: string, limit = 100): LedgerEventRow[] {
  const rows = kind
    ? this.db.prepare(
        `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
         FROM events WHERE tenant_id = ? AND kind = ? ORDER BY seq DESC LIMIT ?`,
      ).all(this.tenant, kind, limit)
    : this.db.prepare(
        `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
         FROM events WHERE tenant_id = ? ORDER BY seq DESC LIMIT ?`,
      ).all(this.tenant, limit)
  return rows as LedgerEventRow[]
}

Key Security Improvements

Aspect Before After
Query construction Template literal with ${where} interpolation Complete, static SQL strings
Code paths Single query with dynamic fragment Explicit conditional branches
Maintainability Implicit logic, harder to audit Explicit, self-documenting paths
Security posture Exploit primitive present No dynamic SQL structure assembly

The purge() Method Fix

The purge() method also received hardening:

// Before
`DELETE FROM events WHERE subject_id = ? AND tenant_id = ? AND kind IN (${placeholders})`

// After  
'DELETE FROM events WHERE subject_id = ? AND tenant_id = ? AND kind IN (' + placeholders + ')'

This change is subtle but important: it uses string concatenation (+) for the placeholders variable (which contains only safe, generated ? characters) rather than template literal interpolation, making the distinction between structure (concatenated) and values (parameterized) visually explicit.

Key Takeaways

  • Template literals in SQL queries create exploit primitives: The ${where} pattern in StateLedger.readEvents() established infrastructure that automated attack tools could leverage through variant analysis.

  • Explicit branches beat dynamic assembly: The fix replaces single-path dynamic SQL with explicit conditional branches—slightly more verbose but dramatically more secure and auditable.

  • Value parameterization requires structural discipline: The purge() method fix demonstrates that even "safe" dynamic elements (the ? placeholder list) should use explicit concatenation rather than template interpolation to maintain clear security boundaries.

  • Multi-tenant data access demands defense in depth: The tenant_id filtering in these queries protects data isolation; SQL injection would bypass this critical security control.

  • Static analysis findings warrant proactive remediation: Semgrep's utils.custom.sql-injection-template-literal rule correctly identified this pattern before it could be exploited in the wild.

How Orbis AppSec Detected This

Element Details
Source The kind parameter in StateLedger.readEvents(kind?: string, ...)
Sink Template literal SQL construction at ts/src/state-ledger.ts:208 where ${where} interpolates dynamic SQL fragments
Missing control Parameterized query structure—the query skeleton was dynamically assembled rather than using static SQL with value-only parameterization
CWE CWE-89: Improper Neutralization of Special Elements in an SQL Command ('SQL Injection')
Fix Replaced template literal interpolation with complete static SQL strings in explicit conditional branches, using ? placeholders for all values passed to .all()

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

This vulnerability in StateLedger illustrates how seemingly minor patterns—using template literals for SQL fragment assembly—create lasting security risks. The fix demonstrates that robust SQL security requires structural discipline: complete query strings, explicit code paths, and rigorous separation between query structure and user data.

For developers working with SQLite in Node.js, remember: better-sqlite3's parameterized query API is your strongest defense. Use it completely—parameterize values, yes, but also eliminate dynamic query structure assembly. The small cost in code verbosity pays enormous dividends in security assurance.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #250

Related Articles

high

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.

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.

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.