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.

Prevention & Best Practices

1. Never use .unsafe() with user-influenced input

The postgres client's unsafe() method should be reserved for truly static SQL where you have complete control over the query string. If any part of the query comes from external sources (parameters, environment variables, config files), use parameterized queries.

2. Use the postgres client's built-in escaping

For identifiers (table names, column names), use client(identifier):

client`SELECT * FROM ${client(tableName)} WHERE id = ${userId}`

For values, the postgres client automatically parameterizes them when you use template literals without unsafe():

client`SELECT * FROM users WHERE email = ${userEmail}`

3. Implement input validation as a secondary defense

While parameterization is the primary defense, validate that table names match expected patterns:

const VALID_TABLES = ['users', 'orders', 'products'];

async function countTable(tableName: string) {
  if (!VALID_TABLES.includes(tableName)) {
    throw new Error(`Invalid table name: ${tableName}`);
  }
  const [row] = await client<{ count: number }[]>`select count(*)::int as count from ${client(tableName)}`;
  return Number(row?.count ?? 0);
}

4. Use static analysis tools

Configure tools like Semgrep, ESLint with security plugins, or CodeQL to detect client.unsafe() patterns:

# Semgrep rule example
rules:
  - id: postgres-unsafe-usage
    pattern: client.unsafe(...)
    message: Avoid client.unsafe() - use parameterized queries instead
    severity: ERROR

5. Apply least privilege

Database scripts should run with minimal necessary permissions. If verify-db.ts only needs to count rows, the database user should only have SELECT privileges, not DROP or DELETE.

6. Review all database interaction code

SQL injection can occur in any layer:
- ORM configurations (TypeORM, Prisma raw queries)
- Migration scripts
- Admin tools and internal scripts
- GraphQL resolvers with raw SQL

References to security standards:

  • CWE-89: This vulnerability maps to CWE-89 (SQL Injection)
  • OWASP A03:2021: Injection vulnerabilities are #3 in the OWASP Top 10
  • OWASP SQL Injection Prevention Cheat Sheet: Recommends parameterized queries as the primary defense

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."

References

Frequently Asked Questions

What is SQL injection via unsafe template literals?

SQL injection via unsafe template literals occurs when JavaScript/TypeScript template strings (backticks) directly embed user-controlled values into SQL queries without proper escaping, allowing attackers to inject malicious SQL commands that the database executes.

How do you prevent SQL injection in TypeScript with postgres client?

Use parameterized queries with the postgres client's built-in escaping: `client<Type>`SELECT * FROM ${client(userInput)}`` instead of `client.unsafe()` with string interpolation. The client() function automatically escapes identifiers, preventing SQL injection.

What CWE is SQL injection?

SQL injection is classified as CWE-89 (Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')). It's one of the most critical web application vulnerabilities in the OWASP Top 10.

Is input validation enough to prevent SQL injection?

No. While input validation helps, it's insufficient as a sole defense. Attackers can bypass validation with encoding tricks. Always use parameterized queries or prepared statements as the primary defense, with validation as a secondary layer.

Can static analysis detect SQL injection?

Yes. Modern static analysis tools like Semgrep, CodeQL, and multi-agent AI scanners can detect SQL injection patterns by tracking data flow from user input (sources) to database queries (sinks) and identifying missing parameterization.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #365

Related Articles

critical

How SQL injection happens in PostgreSQL dictionary synchronization and how to fix it

A critical SQL injection vulnerability in `zhparser--2.1.sql` allowed attackers to execute arbitrary SQL commands by crafting malicious database names. The vulnerability existed because the dictionary synchronization function constructed COPY commands using string concatenation without proper escaping. This fix implements parameterized queries to safely handle database identifiers.

critical

SQL Injection via SQLite's %s Format Specifier in LR2_statlong.cpp ReadPlayerScore()

A critical SQL injection vulnerability was discovered in `LR2/LR2_statlong.cpp` at line 42, where `sqlite3_snprintf` used the `%s` format specifier instead of `%q` to interpolate a player ID into a SQL query. This single-character difference meant that single quotes in the player ID were inserted verbatim, allowing an attacker to break out of the SQL string literal and inject arbitrary commands. The fix changes `%s` to `%q`, which doubles all single quotes to properly escape them.

critical

Buffer Overflow in hoeldb.c: How sprintf() Threatened a Racing Sim's Database Layer

A critical buffer overflow vulnerability was discovered in `src/simmonitor/db/hoeldb.c`, where fixed-size heap buffers (150 and 250 bytes) were allocated with `malloc()` and then written to using `sprintf()` without any bounds checking. The fix replaces these unsafe patterns with `asprintf()` for dynamic allocation and `calloc()` for row data buffers, eliminating both the overflow risk and a related uninitialized memory hazard.

low

SQL Injection via String Formatting: How Parameterized Queries Save the Day

A database query in DBeaver's Altibase extension was constructing SQL statements using `String.format()` with user-controlled input, creating a classic SQL injection vulnerability. The fix replaces the unsafe string interpolation with parameterized queries using `PreparedStatement`, ensuring user input is always treated as data rather than executable SQL. This type of vulnerability is deceptively simple to introduce but equally simple to fix once you know what to look for.

high

SQL Injection in OceanBase Connector: How f-Strings Can Sink Your RAG Platform

A critical SQL injection vulnerability was discovered and patched in the OceanBase database connector used by a RAG (Retrieval-Augmented Generation) platform, where user-controlled filter expressions were directly embedded into SQL WHERE clauses using Python f-strings without any parameterization or validation. This flaw exposed the platform's entire knowledge base to complete compromise, including unauthorized data access, modification, and deletion. The fix replaces unsafe string interpolation

critical

How command injection happens in Go ffmpeg-go and how to fix it

A critical command injection vulnerability (CVE-2026-41179, CWE-78) was discovered in `drivers/local/util.go` of a Go media processing service, where user-controlled file paths were passed to `ffmpeg.Input()` without filtering shell metacharacters. Although a `sanitizeFilePath()` function existed to validate paths, it failed to reject characters like `;`, `|`, and backticks that could be weaponized if the underlying ffmpeg-go library constructs shell commands internally. The fix adds a targeted