Introduction
In a database administration tool within packages/admin/src/shared/tools/database-tools.ts, we discovered a high-severity SQL injection vulnerability at lines 88 and 176. The vulnerable code constructed PostgreSQL queries using JavaScript template literals with schema names directly interpolated into the SQL string: ARRAY[${schemas.map((s: string) => \'${s}'`).join(",")}]`. This pattern created an injection point where malicious schema names could break out of the quoted context and execute arbitrary SQL commands. While this code served an internal admin endpoint with restricted access, it represented an exploit primitive that could be chained with other vulnerabilities by sophisticated attackers or automated exploit tools.
The Vulnerability Explained
The vulnerability existed in two database inspection functions within the admin tools: list_tables and generate_types. Let's examine the vulnerable code from line 88:
case "list_tables": {
const sql = `SELECT schemaname as schema, tablename as table, tableowner as owner FROM pg_tables WHERE schemaname = ANY(ARRAY[${schemas.map((s: string) => `'${s}'`).join(",")}]) ORDER BY schemaname, tablename;`;
const r = await execSql(sql);
text = r.ok ? formatTableList(r.data, schemas) : `❌ Failed (${r.status})`;
break;
}
The problem lies in how schema names are incorporated into the SQL query. The code manually wraps each schema name in single quotes using `'${s}'`, then joins them with commas to create an array literal for PostgreSQL's ANY(ARRAY[...]) construct.
The Attack Vector:
An attacker who could control schema names (even partially) could inject SQL by including a single quote to break out of the string context. For example, if a schema name contained:
public', 'public']) OR 1=1 --
The resulting SQL would become:
SELECT schemaname as schema, tablename as table, tableowner as owner
FROM pg_tables
WHERE schemaname = ANY(ARRAY['public', 'public']) OR 1=1 --'])
ORDER BY schemaname, tablename;
The injected OR 1=1 would bypass the schema filtering entirely, exposing all tables across all schemas. More sophisticated payloads could:
- Extract sensitive data from other schemas:
public']) UNION SELECT table_schema, table_name, 'stolen' FROM information_schema.tables -- - Modify database contents if the connection has write privileges
- Execute PostgreSQL functions to read files or establish network connections
- Escalate privileges depending on the database user's permissions
The same vulnerability existed in the generate_types function at line 176, which queries information_schema.tables and information_schema.columns to generate TypeScript type definitions.
Real-World Impact:
While the PR description notes this is an "internal/admin endpoint with restricted access," the vulnerability still poses significant risks:
- Defense in Depth Violation: Security should never rely on a single layer. If authentication is bypassed through a separate vulnerability, this SQL injection becomes directly exploitable.
- Insider Threats: Malicious insiders with admin access could exploit this to exceed their intended privileges.
- Exploit Chaining: Modern automated exploit development tools can chain multiple "unexploitable" primitives together to achieve code execution.
The Fix
The fix replaces manual string quoting with a dedicated quoteSchemaLiteral() function that properly escapes special characters. Here's the before and after comparison for the list_tables function:
Before (Vulnerable):
const sql = `SELECT schemaname as schema, tablename as table, tableowner as owner FROM pg_tables WHERE schemaname = ANY(ARRAY[${schemas.map((s: string) => `'${s}'`).join(",")}]) ORDER BY schemaname, tablename;`;
After (Secure):
const sql = `SELECT schemaname as schema, tablename as table, tableowner as owner FROM pg_tables WHERE schemaname = ANY(ARRAY[${schemas.map((s: string) => quoteSchemaLiteral(s)).join(",")}]) ORDER BY schemaname, tablename;`;
The identical change was applied to the generate_types function at line 176.
How This Fix Works:
The quoteSchemaLiteral() function (which must be implemented elsewhere in the codebase or imported from a PostgreSQL library) performs proper escaping by:
- Doubling single quotes: Any
'character in the input becomes'', preventing breakout from the string context - Wrapping in quotes: The result is wrapped in single quotes to form a valid PostgreSQL string literal
- Handling edge cases: Proper implementations also handle backslashes, null bytes, and other special characters according to PostgreSQL's string literal rules
For example, if an attacker tries to inject public', 'public']) OR 1=1 --, the function would transform it to:
'public'', ''public'']) OR 1=1 --'
This is interpreted by PostgreSQL as a single string literal containing the literal text public', 'public']) OR 1=1 --, not as SQL code. The injection attempt becomes harmless data.
Why This Approach Works:
Unlike manual escaping attempts that developers might miss edge cases for, using a dedicated escaping function:
- Leverages PostgreSQL-specific knowledge of all special characters and contexts
- Is maintained and tested by security-conscious library developers
- Provides a clear audit trail showing security-conscious coding
- Prevents common mistakes like forgetting to escape backslashes or handling Unicode edge cases
Prevention & Best Practices
To prevent SQL injection vulnerabilities in TypeScript and JavaScript database code:
1. Use Parameterized Queries as Default
The gold standard for SQL injection prevention is parameterized queries (also called prepared statements):
// Best practice - parameterized query
const result = await db.query(
'SELECT * FROM pg_tables WHERE schemaname = ANY($1)',
[schemas]
);
With parameterized queries, the database engine keeps SQL code and data completely separate. User input never becomes part of the SQL syntax tree.
2. When Dynamic SQL is Necessary, Use Proper Escaping Functions
Some queries require dynamic SQL construction (like this case with variable array lengths). When parameterization isn't possible:
- Never manually construct SQL with template literals or string concatenation
- Always use database-specific escaping functions from trusted libraries
- For PostgreSQL: Use
pg-formatlibrary'sliteral()or similar functions - For MySQL: Use
mysql.escape()orconnection.escapeId() - For SQL Server: Use proper parameterization or the library's escaping functions
3. Apply Defense in Depth
- Input Validation: Validate schema names against a whitelist of expected patterns (alphanumeric, underscores)
- Least Privilege: Ensure database connections use accounts with minimal necessary permissions
- Query Allowlisting: Where possible, restrict admin tools to predefined queries rather than dynamic construction
- Regular Security Audits: Use static analysis tools to catch these patterns during development
4. Static Analysis Integration
Configure tools like Semgrep to detect template literal SQL construction:
rules:
- id: sql-injection-template-literal
pattern: |
$SQL = `...${...}...`
message: SQL query uses template literal interpolation
severity: WARNING
languages: [typescript, javascript]
5. Security Training
Educate developers that template literals are not safe for SQL construction, even though they feel modern and convenient. The backtick syntax provides no security benefits over string concatenation.
Standards and References
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
- OWASP: SQL Injection is #3 in the OWASP Top 10 (2021) under "Injection"
- OWASP SQL Injection Prevention Cheat Sheet: Comprehensive guidance on parameterized queries and escaping
Key Takeaways
- Template literals provide zero SQL injection protection: The modern JavaScript
`${variable}`syntax is just as vulnerable as old-style"" + variableconcatenation when constructing SQL queries. - The
list_tablesandgenerate_typesfunctions in database-tools.ts were vulnerable because they manually quoted schema names with`'${s}'`instead of using proper escaping. - Replacing manual quoting with
quoteSchemaLiteral()eliminates the injection vector by properly escaping all PostgreSQL special characters, including single quotes that could break out of the string context. - Admin endpoints need security too: Even "internal" tools should follow secure coding practices, as they represent exploit primitives that sophisticated attackers can chain together.
- Automated detection works: Static analysis tools like Semgrep successfully identified this vulnerability pattern, enabling proactive fixes before exploitation.
How Orbis AppSec Detected This
- Source: Schema names passed to database inspection functions (
list_tables,generate_types) in the admin tool interface - Sink: Direct template literal interpolation into SQL queries at
database-tools.ts:88anddatabase-tools.ts:176where${schemas.map((s: string) => \'${s}'`).join(",")}` constructs PostgreSQL array literals - Missing control: No escaping or sanitization of schema names before SQL interpolation; manual quoting with
`'${s}'`doesn't prevent single-quote breakout attacks - CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
- Fix: Replaced manual string quoting with
quoteSchemaLiteral(s)function that properly escapes PostgreSQL special characters
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 database-tools.ts demonstrates how modern JavaScript syntax can create a false sense of security. Template literals look clean and feel safe, but they provide no protection against injection attacks when constructing SQL queries. The fix—replacing manual quoting with the quoteSchemaLiteral() function—follows the fundamental security principle of using battle-tested escaping functions rather than rolling your own.
For developers working on similar admin tools or database utilities, remember that every piece of user-controlled data that enters a SQL query must be either parameterized or properly escaped using database-specific functions. No exceptions, no shortcuts. Even internal tools deserve secure coding practices, because today's isolated admin endpoint could become tomorrow's attack vector when chained with other vulnerabilities.
By proactively removing exploit primitives like this one, we raise the bar against increasingly sophisticated automated attack tools and make our applications more resilient against both current and future threats.