Back to Blog
high SEVERITY8 min read

TrackOptionsManager DDL: Template-Literal SQL Injection Closed

The `TrackOptionsManager` service built its `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN alias` statements by interpolating a `DEFAULT_ALIAS` constant directly inside single quotes in a JavaScript template literal, and its private `_query()` helper had no parameter channel at all. The fix routes the default value through `mysql.escape()` and gives `_query(q, params = [])` a real bound-parameter argument that is forwarded to `db.query()`. This removes an injection primitive on a schema-bootstra

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 11, 2026•Reviewed September 19, 2026

Answer Summary

The affected code is the first-party `TrackOptionsManager` service class, specifically its schema bootstrap and alias-column migration routines and the private `_query()` helper; no published package or version range is involved. Because the `alias` column's `DEFAULT '${DEFAULT_ALIAS}'` clause was assembled by string interpolation inside a template literal, any value containing a single quote — for instance one sourced from configuration or an environment variable during a later refactor — would break out of the string literal and append attacker-chosen DDL to a statement that executes with table-altering privileges. The fix wraps the default in `mysql.escape(DEFAULT_ALIAS)` and extends `_query(q, params = [])` so callers can pass bound parameters to `db.query()`; there is no released version number, only the hardening commit. No CWE was assigned to this finding.

Vulnerability at a Glance

cweN/A
fix`mysql.escape(DEFAULT_ALIAS)` for the literal, plus `_query(q, params = [])` forwarding bound parameters to `db.query()`
riskInjected DDL executes on the startup/migration path with schema-altering privileges
languageJavaScript (Node.js, ESM, mysql driver)
root cause`DEFAULT_ALIAS` interpolated inside single quotes in `CREATE TABLE` / `ALTER TABLE` template literals, with `_query()` offering no parameter channel
vulnerabilitySQL injection via JavaScript template-literal query construction

Summary

The TrackOptionsManager service built its CREATE TABLE and ALTER TABLE ... ADD COLUMN alias statements by interpolating a DEFAULT_ALIAS constant directly inside single quotes in a JavaScript template literal, and its private _query() helper had no parameter channel at all. The fix routes the default value through mysql.escape() and gives _query(q, params = []) a real bound-parameter argument that is forwarded to db.query(). This removes an injection primitive on a schema-bootstrap path that runs at startup with DDL privileges.

Introduction

TrackOptionsManager is a small persistence service that keeps per-user playback preferences — a track identifier, a display title, a user-chosen alias, and start_ms / end_ms trim points — in a MySQL table called track_options. Like a lot of self-bootstrapping services, it owns its own schema: on construction it issues a CREATE TABLE IF NOT EXISTS, and it carries a one-shot migration that adds the alias column to installations that predate it.

Both of those statements were assembled with template literals, and one of the interpolations landed inside a SQL string literal:

alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT '${DEFAULT_ALIAS}',

That single pair of hand-written quotes is the whole finding. The value being interpolated is a module constant today, so nothing is exploitable right now — but the surrounding helper, _query(q), accepted only a query string. There was no way for any caller in this class to bind a value even if it wanted to. That is the difference between a bug and a primitive: the code shape guarantees that the next person who needs a dynamic default, a per-guild alias, or an environment-driven configuration value will do it by string concatenation, because the API offers nothing else.

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code) — corrected in the TrackOptionsManager hardening commit
Ecosystem npm (Node.js ESM service code, mysql driver)
CVE / GHSA not assigned
CWE unknown (not supplied with this finding)

The Vulnerability Explained

There were two vulnerable interpolations, both around the alias column.

The first is in the schema bootstrap, inside CREATE TABLE IF NOT EXISTS track_options:

user_id VARCHAR(32) NOT NULL,
track_identifier VARCHAR(512) NOT NULL,
track_title VARCHAR(512) NOT NULL DEFAULT '',
alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT '${DEFAULT_ALIAS}',
start_ms INT UNSIGNED NOT NULL DEFAULT 0,
end_ms INT UNSIGNED NOT NULL DEFAULT 0,
bot_id VARCHAR(32) NOT NULL DEFAULT '',

The second is in the migration branch, which first probes with SHOW COLUMNS FROM track_options LIKE 'alias' and, if the column is absent, runs:

await this._query(`ALTER TABLE track_options ADD COLUMN alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT '${DEFAULT_ALIAS}' AFTER track_title`);

The exact problem

'${DEFAULT_ALIAS}' is a SQL string literal whose contents are pasted in without escaping. A value containing a single quote terminates the literal early, and everything after it is parsed as SQL. Two properties of this particular path make that worse than the average injectable SELECT:

  1. It is a DDL statement. The injection point is not in a WHERE clause where the damage is bounded by row visibility. It sits in a column definition list, where trailing text is interpreted as more schema. A value like x', pwn TEXT NOT NULL DEFAULT 'a closes the default, appends an entire extra column, and reopens a literal so the statement still parses.
  2. It runs with schema-altering privileges at startup. The ALTER TABLE migration also performs DROP INDEX uq_user_track_bot and ADD UNIQUE KEY uq_user_track_alias_bot (user_id, track_identifier, alias, bot_id). Whatever database account executes this path can already drop indexes and rewrite tables, so injected DDL inherits the strongest credentials the service holds — not the read-mostly ones used for normal alias lookups.

How this becomes exploitable

DEFAULT_ALIAS is a hard-coded constant, so today an attacker cannot reach it. The realistic exploitation route is a two-step chain, and it is exactly the chain automated exploit tooling is good at spotting:

  • A future change makes the default configurable — const DEFAULT_ALIAS = process.env.DEFAULT_TRACK_ALIAS ?? 'default' is the one-line diff that does it — or a per-bot default is threaded in alongside the existing setBotId(id) call that populates this.botId.
  • Any attacker who can influence that configuration source (a compromised deploy variable, an admin-facing settings endpoint, a container env injection) now controls a fragment of a CREATE TABLE / ALTER TABLE statement.

The concrete impact for a service running this code is schema corruption rather than data exfiltration: an injected column definition can add storage the application does not know about, change the uniqueness guarantees that uq_user_track_alias_bot is supposed to enforce over (user_id, track_identifier, alias, bot_id), or simply make the bootstrap statement fail so the service never comes up. If the connection was created with multipleStatements: true, the ' breakout can be followed by ; and an arbitrary second statement, which turns it into full arbitrary SQL under DDL-capable credentials.

And notice the structural blocker: even if a developer wanted to bind that value, _query(q) had no second argument to bind it with.

The Fix

Three coordinated changes, all inside TrackOptionsManager.

1. Escape the default in the bootstrap CREATE TABLE.

Before:

alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT '${DEFAULT_ALIAS}',

After:

alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT ${mysql.escape(DEFAULT_ALIAS)},

The hand-written quotes are gone because mysql.escape() returns a complete quoted literal — it adds the delimiters itself and backslash-escapes any quote, backslash, NUL, newline, or control character in the value. Leaving the original quotes around the call would have produced ''default'' and reopened the same hole, so removing them is part of the fix, not cosmetic.

2. Escape the default in the ALTER TABLE migration.

await this._query(`ALTER TABLE track_options ADD COLUMN alias VARCHAR(${MAX_ALIAS_LEN}) NOT NULL DEFAULT ${mysql.escape(DEFAULT_ALIAS)} AFTER track_title`);

Same transformation, same reasoning. This one matters more, because it is the statement that also drops and recreates a unique index in the same migration block.

3. Give _query() a real parameter channel.

Before and after, the private helper:

/** @private Execute a raw SQL query. @param {string} q @returns {Promise<Array>} */
_query(q) {
  return new Promise((resolve, reject) => {
    this.db.query(q, (error, results) => {

becomes:

/** @private Execute a raw SQL query. @param {string} q @param {Array} [params] @returns {Promise<Array>} */
_query(q, params = []) {
  return new Promise((resolve, reject) => {
    this.db.query(q, params, (error, results) => {

This is the change that prevents a recurrence. Every INSERT, UPDATE, and SELECT in this class now has a first-class way to pass values — this._query('... WHERE user_id = ? AND alias = ?', [userId, alias]) — instead of splicing them into a template literal. The default params = [] keeps all existing single-argument call sites, including the SHOW COLUMNS FROM track_options LIKE 'alias' probe, byte-for-byte identical in behaviour.

Why escaping and not a placeholder here?

A fair question: if parameterization is the right answer, why does the DDL use mysql.escape() rather than ?? Because MySQL does not accept placeholders in a DEFAULT clause of a column definition, nor in a VARCHAR(n) length, nor for identifiers. DDL is structure, and structure cannot be bound. When a value must be inlined into structure, driver-level escaping is the correct tool — and MAX_ALIAS_LEN stays raw precisely because it is a numeric length that must appear as a bare literal.

Key Takeaways

  • The alias VARCHAR(...) NOT NULL DEFAULT '${...}' pattern is injectable specifically because the hand-written single quotes make the interpolation a string literal; mysql.escape() supplies its own quotes, so those quotes must be deleted when you convert.
  • Schema-bootstrap and migration code is the highest-privilege SQL in most services — the same block that ran the vulnerable ADD COLUMN alias also issued DROP INDEX uq_user_track_bot, so injected DDL there inherits table-altering rights, not read rights.
  • A private helper like _query(q) with no parameter argument is itself the vulnerability: it forces every caller into string concatenation. Adding params = [] and forwarding it to db.query(q, params, cb) is what actually removes the primitive.
  • Not every SQL fragment can be parameterized. VARCHAR(${MAX_ALIAS_LEN}) and DEFAULT clauses are structural, so the rule for those is "hard-coded constant, or coerced and range-checked" — never a raw string from config.
  • "Constant today" is not a safety property. DEFAULT_ALIAS was inert until someone made it process.env-driven; the escape call makes that future refactor harmless instead of exploitable.

How Orbis AppSec Detected This

  • Source: the DEFAULT_ALIAS module constant, interpolated into a template literal that reaches SQL — a value with no escaping contract, positioned so that any future configuration- or environment-derived assignment becomes attacker-influenceable input.
  • Sink: db.query() as reached through the private _query() helper of TrackOptionsManager, executing a CREATE TABLE IF NOT EXISTS track_options statement and an ALTER TABLE track_options ADD COLUMN alias ... migration.
  • Missing control: no escaping of the value placed inside the DEFAULT '...' string literal, and no bound-parameter argument on _query(), so no call site in the class had a safe alternative to concatenation.
  • CWE: unknown — no CWE was assigned to this finding. It was reported as a template-literal SQL construction pattern at high severity.
  • Fix: DEFAULT_ALIAS is now emitted via mysql.escape() in both DDL statements, and _query(q, params = []) forwards bound parameters to db.query().

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

Nothing about this finding was exploitable in the shipped configuration — and that is the point of fixing it anyway. TrackOptionsManager had a SQL string literal whose contents came from an unescaped interpolation, on the one code path in the service that holds CREATE TABLE and ALTER TABLE privileges, behind a helper that structurally could not bind a value. Any one of those three facts is a smell; together they are a loaded primitive waiting for a one-line refact

Prevention and further reading

Frequently Asked Questions

Why did `mysql.escape()` replace the surrounding single quotes in the `DEFAULT '${DEFAULT_ALIAS}'` clause instead of being added inside them?

`mysql.escape()` returns a fully quoted SQL literal, so it supplies its own delimiters. Leaving the hand-written quotes in place would have produced `''value''` and reintroduced the same breakout, which is why the fixed line reads `DEFAULT ${mysql.escape(DEFAULT_ALIAS)}`.

Is `MAX_ALIAS_LEN` in `alias VARCHAR(${MAX_ALIAS_LEN})` still interpolated after the fix?

Yes, and deliberately so — a `VARCHAR` length is part of the statement's structure and cannot be bound as a parameter. It is safe only as long as `MAX_ALIAS_LEN` stays a hard-coded numeric constant; if it ever becomes configurable it must be coerced with `Number()` and range-checked.

Does the new `params` argument on `_query()` change behaviour for existing callers like the `SHOW COLUMNS FROM track_options LIKE 'alias'` probe?

No. `params` defaults to `[]`, and the mysql driver treats a query with an empty parameter array identically to one with no parameters, so every existing single-argument call site keeps its exact prior behaviour.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

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.

critical

SQL's Insert() and Update() Methods Used F-String Interpolation in u2share_batch_give_sugar

The SQL helper class in u2share_batch_give_sugar used Python f-strings to construct INSERT and UPDATE queries, creating SQL injection vulnerabilities even though values appeared to come from internal constants. The fix replaces all f-string query construction with sqlite3 parameterized queries using `?` placeholders, eliminating string interpolation entirely from the database path.