Back to Blog
critical SEVERITY6 min read

How SQL injection via unsafe template literals happens in TypeScript database scripts and how to fix it

A critical SQL injection vulnerability in `scripts/verify-db.ts` allowed attackers to execute arbitrary SQL commands by manipulating table names passed to the `countTable()` function. The script used `client.unsafe()` with string interpolation, directly embedding unsanitized input into SQL queries. The fix replaced the unsafe pattern with parameterized queries using the postgres client's built-in escaping.

O
By Orbis AppSec
Published July 8, 2026Reviewed July 8, 2026

Answer Summary

SQL injection (CWE-89) in TypeScript's verify-db.ts script occurred when `client.unsafe()` combined template literals with direct string interpolation of the `tableName` parameter. This allowed attackers who control script parameters or environment variables to inject malicious SQL commands. The fix replaced `client.unsafe()` with parameterized queries using `client<{ count: number }[]>` and proper escaping via `client(tableName)`, ensuring user input never directly appears in SQL queries.

Vulnerability at a Glance

cweCWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
fixReplaced with parameterized query using client(tableName) for automatic escaping
riskArbitrary SQL execution allowing data theft, modification, or deletion
languageTypeScript with postgres client library
root causeDirect string interpolation of tableName into SQL query using client.unsafe()
vulnerabilitySQL Injection via unsafe template literals

Introduction

In a database verification script, we discovered a critical SQL injection vulnerability in scripts/verify-db.ts at line 29. The countTable() function used the postgres client's unsafe() method with a template literal that directly interpolated the tableName parameter into a SQL query. This seemingly innocent pattern—common in scripts that developers assume are "internal only"—created a severe security risk. An attacker who could influence script execution parameters, environment variables, or configuration files could inject arbitrary SQL commands, potentially exfiltrating sensitive data, modifying records, or even dropping entire tables.

The vulnerability was particularly dangerous because verify-db.ts is production code, not a test-only script, and the postgres client's unsafe() method explicitly bypasses the library's built-in SQL injection protections.

The Vulnerability Explained

Let's examine the vulnerable code from scripts/verify-db.ts:

async function countTable(tableName: string) {
  const [row] = await client.unsafe<{ count: number }[]>(`select count(*)::int as count from ${tableName}`);
  return Number(row?.count ?? 0);
}

The problem lies in line 29 where client.unsafe() is combined with a template literal that directly embeds ${tableName} into the SQL query. The unsafe() method exists for rare edge cases where developers need to execute dynamic SQL, but it completely disables the postgres client's parameterization protections.

How could this be exploited?

Consider these attack scenarios:

  1. Table Drop Attack: If an attacker controls the script's execution parameters, they could pass:
    tableName = "users; DROP TABLE users; --"
    The resulting SQL would be:
    sql select count(*)::int as count from users; DROP TABLE users; --
    This would first count the users table, then immediately drop it.

  2. Data Exfiltration: An attacker could inject a UNION-based payload:
    tableName = "users UNION SELECT password FROM admin_credentials WHERE '1'='1"
    This would expose sensitive data from other tables.

  3. Boolean-based Blind SQL Injection: Even if results aren't directly visible:
    tableName = "users WHERE 1=1 AND (SELECT COUNT(*) FROM sensitive_table) > 0 --"
    This allows attackers to infer information about the database structure.

Real-world impact: The verify-db.ts script appears to be part of database health checks or initialization routines. If this script runs with elevated database privileges (common for verification scripts), an attacker could compromise the entire database. The scanner flagged this as "Likely exploitable" because scripts often accept parameters from configuration files, environment variables, or command-line arguments—all potential attack vectors.

The Fix

The fix replaced the unsafe pattern with proper parameterized queries. Here's the before and after:

Before (vulnerable):

async function countTable(tableName: string) {
  const [row] = await client.unsafe<{ count: number }[]>(`select count(*)::int as count from ${tableName}`);
  return Number(row?.count ?? 0);
}

After (secure):

async function countTable(tableName: string) {
  const [row] = await client<{ count: number }[]>`select count(*)::int as count from ${client(tableName)}`;
  return Number(row?.count ?? 0);
}

What changed?

  1. Removed client.unsafe(): The fix eliminates the unsafe method entirely, switching to the standard parameterized query syntax.

  2. Added client(tableName) escaping: The tableName parameter is now wrapped in client(tableName), which tells the postgres library to treat this as an identifier that needs proper escaping, not raw SQL.

  3. Preserved type safety: The query still uses TypeScript generics (client<{ count: number }[]>) to maintain type safety for the result.

How this solves the problem:

The postgres client library's client() function performs identifier escaping, which means:
- Special characters in tableName are properly escaped
- SQL keywords are quoted to prevent interpretation as commands
- Multi-statement injections are prevented because the identifier is treated as a single atomic value

If an attacker tries to inject "users; DROP TABLE users; --", the postgres client will escape it to something like "users; DROP TABLE users; --" (with quotes), treating the entire string as a literal table name. The database will look for a table with that exact name (which doesn't exist), rather than executing the DROP command.

Key Takeaways

  • The countTable() function in verify-db.ts used client.unsafe() with template literal interpolation, creating a direct SQL injection vulnerability at line 29
  • "Internal" scripts are still attack surfaces: Just because code is in a /scripts directory doesn't mean it's safe from exploitation—attackers can manipulate environment variables, configuration files, or script parameters
  • The postgres client's client(identifier) syntax is specifically designed for safe identifier escaping: Use it for table names, column names, and other SQL identifiers
  • Never trust the .unsafe() method with any external input: The method name itself is a warning—it bypasses all SQL injection protections
  • Parameterized queries prevent SQL injection more reliably than input validation: Validation can be bypassed with encoding tricks, but proper parameterization makes injection structurally impossible

How Orbis AppSec Detected This

  • Source: The tableName parameter in the countTable() function, potentially controllable through script arguments, environment variables, or configuration files
  • Sink: client.unsafe() method call at line 29 in scripts/verify-db.ts, which directly interpolates the unsanitized tableName into a SQL query using template literal syntax
  • Missing control: No parameterization or identifier escaping—the tableName value flows directly into the SQL string without any sanitization
  • CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
  • Fix: Replaced client.unsafe() with parameterized query syntax and wrapped tableName in client(tableName) for proper identifier escaping

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 SQL injection vulnerability in verify-db.ts demonstrates why security cannot be an afterthought, even in "internal" scripts. The combination of client.unsafe() and template literal interpolation created a critical vulnerability that could have allowed attackers to execute arbitrary SQL commands. By switching to parameterized queries with proper identifier escaping, the fix eliminates the injection vector while maintaining the script's functionality. Remember: when working with databases, always use your client library's parameterization features, treat .unsafe() methods with extreme caution, and validate that security controls are present even in code that seems "internal only."

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #365

Related Articles

high

TrackOptionsManager DDL: Template-Literal SQL Injection Closed

The `TrackOptionsManager` service built its `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN alias` statements by interpolating a `DEFAULT_ALIAS` constant directly inside single quotes in a JavaScript template literal, and its private `_query()` helper had no parameter channel at all. The fix routes the default value through `mysql.escape()` and gives `_query(q, params = [])` a real bound-parameter argument that is forwarded to `db.query()`. This removes an injection primitive on a schema-bootstra

high

How SQL injection via template literals happens in Node.js SQLite and how to fix it

A SQL injection vulnerability in `src/lib/codex-state.mjs` allowed dynamic column names to reach SQL queries through JavaScript template literals. The fix implements defense-in-depth with strict identifier validation using `SAFE_IDENTIFIER` regex before query construction.

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.