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:
- It is a DDL statement. The injection point is not in a
WHEREclause 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 likex', pwn TEXT NOT NULL DEFAULT 'acloses the default, appends an entire extra column, and reopens a literal so the statement still parses. - It runs with schema-altering privileges at startup. The
ALTER TABLEmigration also performsDROP INDEX uq_user_track_botandADD 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 existingsetBotId(id)call that populatesthis.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 TABLEstatement.
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 aliasalso issuedDROP 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. Addingparams = []and forwarding it todb.query(q, params, cb)is what actually removes the primitive. - Not every SQL fragment can be parameterized.
VARCHAR(${MAX_ALIAS_LEN})andDEFAULTclauses 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_ALIASwas inert until someone made itprocess.env-driven; the escape call makes that future refactor harmless instead of exploitable.
How Orbis AppSec Detected This
- Source: the
DEFAULT_ALIASmodule 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 ofTrackOptionsManager, executing aCREATE TABLE IF NOT EXISTS track_optionsstatement and anALTER 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_ALIASis now emitted viamysql.escape()in both DDL statements, and_query(q, params = [])forwards bound parameters todb.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