SQL Injection in Toolforge Database Creation: Unicode Backtick Bypass
A critical SQL injection vulnerability existed in the database creation routine of a Node.js wiki library. The vulnerability allowed attackers to inject arbitrary SQL commands by exploiting a weak input validation check that did not account for Unicode characters that visually or functionally resemble the backtick metacharacter.
Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) |
| Ecosystem | Node.js |
| CVE / GHSA | not assigned |
| CWE | CWE-89 (SQL Injection) |
The Vulnerability Explained
The create_database function accepts a dbname parameter and constructs a SQL query by directly concatenating it into the command string:
if (dbname.includes('`'))
throw new Error('Invalid database name: [' + dbname + ']');
run_SQL('CREATE DATABASE IF NOT EXISTS `' + dbname + '`', function(
The validation attempts to block backticks by checking if the string contains the ASCII backtick character (U+0060). However, this check is insufficient because MySQL also recognizes Unicode lookalike characters as identifier delimiters in certain parsing contexts.
The Attack
An attacker who controls the dbname parameter can inject SQL by substituting Unicode variants of the backtick:
- U+2018 (left single quotation mark): '
- U+2019 (right single quotation mark): '
- U+300C (left corner bracket, used in CJK text): 「
- U+300D (right corner bracket): 」
For example, passing dbname = "test'; DROP TABLE users; --" wrapped in U+2018 characters would bypass the includes('')check becauseincludes()` searches only for U+0060. Depending on the MySQL version and character set configuration, the Unicode character may be interpreted as a backtick, allowing the injected SQL to execute:
CREATE DATABASE IF NOT EXISTS `test'; DROP TABLE users; --`
The attacker gains the ability to:
- Create databases under attacker-controlled names
- Execute arbitrary SQL by nesting commands
- Exfiltrate or corrupt data
- Disrupt service availability
This is particularly dangerous in a library distributed to multiple downstream consumers, each of whom passes untrusted user input to create_database without additional validation.
The Fix
The fix replaces the character-based check with a strict regex-based allowlist that explicitly enumerates the only safe characters for a database identifier:
if (!/^[A-Za-z0-9_$]+$/.test(dbname))
throw new Error('Invalid database name: [' + dbname + ']');
run_SQL('CREATE DATABASE IF NOT EXISTS `' + dbname + '`', function(
How This Solves the Problem
-
Allowlist instead of blocklist: Rather than attempting to block dangerous characters (which is inherently incomplete), the regex specifies exactly which characters are permitted: ASCII letters, digits, underscores, and dollar signs.
-
No Unicode lookalikes: The
[A-Za-z0-9_$]character class matches only ASCII code points. Unicode variants such as U+2018 will not match and will be rejected. -
Anchor to string boundaries: The
^and$anchors ensure the entire string matches the pattern, not just a substring. This prevents partial injection. -
No escaping ambiguity: By restricting the identifier to safe ASCII characters, the fix eliminates any possibility of the identifier being misinterpreted as SQL syntax, even if the database name is later used in other contexts without quoting.
The regex pattern is more restrictive than MySQL's full identifier rules, but this is intentional—it trades maximum flexibility for maximum safety, which is the correct trade-off for a security boundary.
Key Takeaways
-
Character-based blocking is incomplete: A check for
includes('')` only catches the specific ASCII backtick and fails against Unicode lookalikes. Unicode-aware validators must use normalization or explicit character-class ranges. -
Allowlists are stronger than blocklists for identifiers: For database names, table names, and other identifier-like parameters, specify the exact safe character set rather than trying to enumerate all dangerous ones.
-
Regex anchors matter: Without
^and$, a pattern like/[A-Za-z0-9_$]/would match any string containing at least one safe character, still permitting injection. Always anchor identifier validation to the full string. -
MySQL's backtick is not just ASCII: MySQL recognizes certain Unicode characters as identifier delimiters depending on the server's SQL mode and character set. Do not assume backtick is the only dangerous character in all contexts—use an allowlist.
-
Libraries distribute vulnerability scope: This code runs in downstream consumers' applications. A fix in the library protects all users at once; a bypass affects all of them simultaneously.
How Orbis AppSec Detected This
Source: The dbname parameter passed to the create_database function, which may come from HTTP request data, user input, or API calls.
Sink: The SQL query string constructed via string concatenation and passed to run_SQL().
Missing control: The validation check relied on a single forbidden character (includes('')`) rather than a strict allowlist of permitted characters.
CWE: CWE-89 (SQL Injection) — untrusted input is incorporated into a SQL command without proper escaping or parameterization.
Fix: Replace the character-based block with a regex allowlist pattern that explicitly permits only ASCII alphanumerics, underscores, and dollar signs.
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
The Toolforge vulnerability demonstrates a common pitfall: assuming a simple character-based check can secure an identifier field. Unicode lookalike characters and the complexity of SQL parsing make this assumption dangerous. The fix—a strict regex allowlist—is a pattern worth adopting for any situation where user-controlled data becomes part of a SQL identifier, database name, or table name. Allowlists, properly anchored and tested against internationalized input, are far more reliable than blocks.