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
Published September 11, 2026Reviewed September 11, 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

high

How SQL injection via template literals happens in Node.js SQLite and how to fix it

A SQL injection vulnerability in `src/lib/codex-state.mjs` allowed dynamic column names to reach SQL queries through JavaScript template literals. The fix implements defense-in-depth with strict identifier validation using `SAFE_IDENTIFIER` regex before query construction.

high

How Python SQLAlchemy Raw Query SQL Injection happens and how to fix it

A high-severity SQL injection vulnerability was fixed in the `skills/last30days/scripts/store.py` file where untrusted input was being concatenated directly into raw SQL queries. The fix replaces string concatenation with SQLAlchemy's TextualSQL prepared statements using named parameters, preventing attackers from manipulating database queries through malicious input.

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

critical

How SQL Injection happens in PHP bulk email systems and how to fix it

A critical SQL injection vulnerability in `admin/utilities/bulkEmailSystem.php` allowed attackers to inject arbitrary SQL through unvalidated database names passed from user input. The fix implements strict input validation using regex pattern matching to ensure only safe database identifiers are processed, preventing exploitation of the bulk email functionality.

high

How SQL Injection Happens in Shell Scripts and How to Fix It

A critical SQL injection vulnerability in the `check_kuota.sh` script allowed attackers to execute arbitrary SQL commands by controlling the USERNAME parameter. The fix implements strict input validation using regex pattern matching to ensure only legitimate username characters are accepted, eliminating the injection vector entirely.

high

How Regular Expression Denial of Service (ReDoS) Happens in Node.js trim-newlines and How to Fix It

CVE-2021-33623 exposed a Regular Expression Denial of Service (ReDoS) vulnerability in the npm package `trim-newlines` versions 1.0.0 and earlier. The vulnerable `.end()` method used an inefficient regex pattern that could cause severe performance degradation when processing malicious input. Upgrading to version 4.0.1 patches the regex implementation and eliminates the attack surface.