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
•Technically reviewed by Anupam Mediratta•Published July 8, 2026•Reviewed 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 detects issues like this automatically. 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

Related Articles

critical

Go ArticleRemove() SQL Injection via fmt.Sprintf IN Clause

The Go backend's `ArticleRemove` function built a SQL `DELETE` statement by joining a caller-supplied slice of article IDs directly into the query string with `fmt.Sprintf`. Because the `aids` values came straight from the frontend with no validation or escaping, an attacker could inject arbitrary SQL into the `IN(...)` clause. The fix replaces string concatenation with a parameterized query using placeholders and a `DB.Exec` argument list.

critical

JdbcSinkFunction.invoke() SQL Injection via Unvalidated Identifiers

The `JdbcSinkFunction.invoke()` method in Lacus's RTC engine built SQL INSERT statements with `String.format()`, placing database name, table name, and column names directly into the query string without validation. Although the actual row values used parameterized `?` placeholders, the identifiers themselves were injectable, letting a crafted sink configuration break out of the intended INSERT statement. The fix adds a strict allowlist regex that rejects any identifier containing characters out

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.

critical

Toolforge Database Creation SQL Injection via Unicode Backtick Bypass

A critical SQL injection vulnerability in the database creation routine of a Node.js wiki library allowed attackers to inject arbitrary SQL by exploiting a weak validation check. The fix replaces a character-based block with a strict allowlist pattern, ensuring only valid database identifiers reach the SQL engine.

critical

heatmap.php SQL Injection: $_REQUEST Parameters in Unparameterized

A critical SQL injection vulnerability in the heatmap data retrieval endpoint allowed attackers to execute arbitrary database commands by manipulating coordinate bounds or time range parameters. The vulnerability affected all six user-controlled $_REQUEST parameters passed directly into query construction without parameterization.

critical

Actual Budget addTransaction.sh SQL Injection via Shell Variable

A critical SQL injection vulnerability in Actual Budget's transaction automation script allowed attackers to manipulate database records through shell variables interpolated directly into SQL strings. The fix introduces proper escaping functions and numeric validation to prevent injection through unquoted fields.