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:
-
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. -
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. -
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?
-
Removed
client.unsafe(): The fix eliminates the unsafe method entirely, switching to the standard parameterized query syntax. -
Added
client(tableName)escaping: ThetableNameparameter is now wrapped inclient(tableName), which tells the postgres library to treat this as an identifier that needs proper escaping, not raw SQL. -
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 inverify-db.tsusedclient.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
/scriptsdirectory 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
tableNameparameter in thecountTable()function, potentially controllable through script arguments, environment variables, or configuration files - Sink:
client.unsafe()method call at line 29 inscripts/verify-db.ts, which directly interpolates the unsanitizedtableNameinto a SQL query using template literal syntax - Missing control: No parameterization or identifier escaping—the
tableNamevalue 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 wrappedtableNameinclient(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."