Back to Blog
high SEVERITY7 min read

How SQL Injection happens in Node.js migration scripts and how to fix it

A high-severity SQL injection vulnerability was discovered in `scripts/setup-d1.mjs`, where migration filenames were directly concatenated into SQL INSERT statements using an inadequate `escapeSqlString` function. An attacker with filesystem write access could craft a malicious filename to execute arbitrary SQL commands against the Cloudflare D1 database. The fix replaces string concatenation with parameterized queries, eliminating the injection surface entirely.

O
By Orbis AppSec
Published August 30, 2026Reviewed August 30, 2026

Answer Summary

This is a SQL injection vulnerability (CWE-89) in a Node.js Cloudflare D1 migration script (`scripts/setup-d1.mjs`). The `applyMigrations` function concatenated migration filenames directly into SQL INSERT statements using a flawed `escapeSqlString` helper that only escaped single quotes. The fix separates the migration tracking INSERT into its own `executeD1Sql` call using a parameterized query (`VALUES (?)`), passing the filename as a bound parameter so it can never be interpreted as SQL syntax.

Vulnerability at a Glance

cweCWE-89
fixReplaced string concatenation with a parameterized query using `?` placeholder and bound parameters
riskArbitrary SQL execution against the Cloudflare D1 database
languageJavaScript (Node.js / ESM)
root causeMigration filenames interpolated into SQL strings through an incomplete escaping function
vulnerabilitySQL Injection via migration filename concatenation

Introduction

The scripts/setup-d1.mjs file manages Cloudflare D1 database migrations for this application — reading .sql files from a migrations directory, executing them, and recording each applied migration by name. It's the kind of infrastructure code that rarely gets a second look after it's written. But buried inside the applyMigrations function was a SQL injection vulnerability that could let an attacker execute arbitrary SQL against the database simply by controlling a filename.

The culprit was a helper called escapeSqlString and a single line of template-literal SQL construction:

const mergedSql = `${sql}\n\nINSERT INTO d1_migrations (name) VALUES ('${escapeSqlString(file)}');`;

This pattern — trusting a sanitization function to make string interpolation safe — is one of the most common sources of SQL injection in real-world codebases.


The Vulnerability Explained

What went wrong in escapeSqlString

The function that was supposed to protect the INSERT statement looked like this:

function escapeSqlString(value) {
  return String(value || '').replaceAll("'", "''");
}

It only escapes single quotes by doubling them. That's a partial defense at best. A filename containing a semicolon, a SQL comment sequence (--), or other metacharacters passes through completely unmodified.

The dangerous construction in applyMigrations

In applyMigrations (around line 234), the migration SQL and the tracking INSERT were merged into a single string:

const mergedSql = `${sql}\n\nINSERT INTO d1_migrations (name) VALUES ('${escapeSqlString(file)}');`;
await executeD1Sql(databaseId, mergedSql);

The variable file comes from a directory listing of the migrations folder. If an attacker can write a file to that directory, they control the value of file directly.

Concrete attack scenario

Consider a file named:

'); DROP TABLE users; --

After passing through escapeSqlString, the single quote is doubled, but the rest survives intact. The assembled SQL becomes:

-- (migration SQL here)

INSERT INTO d1_migrations (name) VALUES (''); DROP TABLE users; --');

The D1 query endpoint receives a multi-statement payload. The DROP TABLE users executes as a legitimate SQL command, and the trailing -- comments out the rest. The d1_migrations table itself, application data tables, or any other accessible object could be targeted.

Even without dropping tables, an attacker could:
- Exfiltrate data by crafting a UNION-based payload embedded in the filename
- Corrupt migration state by inserting fake records into d1_migrations
- Escalate privileges if the D1 API token has broader permissions than migration tracking requires


The Fix

The fix makes two targeted, surgical changes that together eliminate the injection surface.

1. Remove escapeSqlString entirely

The broken helper is deleted. There is no safe way to use it for the purpose it was serving — the entire approach of sanitizing values before interpolation is replaced with something that doesn't require sanitization at all.

2. Separate the INSERT and use a parameterized query

Before:

const mergedSql = `${sql}\n\nINSERT INTO d1_migrations (name) VALUES ('${escapeSqlString(file)}');`;
await executeD1Sql(databaseId, mergedSql);

After:

await executeD1Sql(databaseId, sql);
await executeD1Sql(databaseId, 'INSERT INTO d1_migrations (name) VALUES (?);', [file]);

The migration SQL runs on its own. The tracking INSERT is a separate call with a ? placeholder and file passed as a bound parameter in the params array.

3. executeD1Sql now accepts parameters

The function signature was updated to support the params argument:

// Before
async function executeD1Sql(databaseId, sql) {
  const data = await cfApiRequest(`/d1/database/${encodeURIComponent(databaseId)}/query`, 'POST', { sql });
  return data.result;
}

// After
async function executeD1Sql(databaseId, sql, params) {
  const body = params ? { sql, params } : { sql };
  const data = await cfApiRequest(`/d1/database/${encodeURIComponent(databaseId)}/query`, 'POST', body);
  return data.result;
}

When params is provided, it's included in the POST body alongside sql. Cloudflare's D1 API handles the binding server-side, ensuring the filename value is never parsed as SQL syntax — regardless of what characters it contains.

Why this works where escaping didn't

Parameterized queries enforce a hard separation between the SQL structure (the query template) and the data (the bound parameters). The database driver transmits them separately; the query planner sees the ? as a typed data slot, not as SQL text to be parsed. No amount of SQL metacharacters in file can change the shape of the query.


Prevention & Best Practices

Always use parameterized queries for user- or filesystem-controlled data

If your SQL API supports bound parameters — and virtually all modern ones do — use them. This includes:
- Cloudflare D1: { sql: 'SELECT * FROM t WHERE id = ?', params: [id] }
- better-sqlite3: stmt.run(value)
- pg (node-postgres): client.query('SELECT $1', [value])
- Prisma / Drizzle / Kysely: parameterization is the default

Treat filenames as untrusted input

Filenames sourced from readdir, glob, or any filesystem operation can be influenced by anyone with write access to that directory. They are not safe to interpolate into SQL, shell commands, or HTML without proper handling.

Validate migration filenames before use

Even with parameterized queries in place, consider adding an allowlist check on migration filenames:

if (!/^\d{4}[-_][\w-]+\.sql$/.test(file)) {
  throw new Error(`Unexpected migration filename: ${file}`);
}

This adds defense-in-depth and makes migration state easier to reason about.

Never combine migration SQL with tracking SQL in one string

Merging two SQL statements with template literals (as the original code did) creates a multi-statement payload that bypasses the intent of parameterized queries. Keep statements separate and parameterize each independently.

Use static analysis to catch injection patterns early

Tools that perform taint analysis — tracing data from a source (filesystem read) through transformations to a sink (SQL execution) — can catch this class of bug before it reaches production. Semgrep rules for SQL injection in JavaScript are available at https://semgrep.dev/r?q=sql-injection+javascript.

Relevant standards:
- OWASP SQL Injection Prevention Cheat Sheet
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command


Key Takeaways

  • escapeSqlString was a false sense of security: Escaping only single quotes while leaving semicolons, comment sequences, and other metacharacters unhandled made the function actively misleading. If you see a custom SQL-escaping helper in a codebase, treat it as a red flag.
  • Filenames are attacker-controlled input: In applyMigrations, the file variable came from readdir — not from a trusted constant. Any value read from the filesystem should be treated with the same suspicion as HTTP input.
  • Merging SQL statements via template literals breaks parameterization: The original design concatenated the migration SQL and the tracking INSERT into one string, making it impossible to safely parameterize the filename. Separating the two calls was a prerequisite for the fix.
  • The D1 params array is the right tool: Cloudflare's D1 query API natively supports bound parameters. There was no need for manual escaping; the correct API was available all along.
  • Deleting escapeSqlString was the right call: Leaving a broken helper in place invites future misuse. Removing it entirely ensures no new code can accidentally rely on it.

How Orbis AppSec Detected This

  • Source: The file variable populated by readdirSync in applyMigrations — a filesystem-controlled string that can be set by anyone with write access to the migrations directory.
  • Sink: The executeD1Sql(databaseId, mergedSql) call at line ~234, where the assembled string (including the unsanitized filename) was POST-ed to the Cloudflare D1 query endpoint.
  • Missing control: The escapeSqlString function only neutralized single-quote characters. No validation of the filename format, no use of bound parameters, and no separation between SQL structure and data values.
  • CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command (SQL Injection)
  • Fix: Replaced the single concatenated executeD1Sql call with two separate calls — one for the migration SQL, one for the tracking INSERT using a ? placeholder and [file] as a bound parameter array.

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 vulnerability is a textbook example of why custom sanitization functions are dangerous: escapeSqlString looked like a solution, but it only addressed one of many possible injection vectors. The real fix wasn't a better escaping function — it was eliminating the need for escaping entirely by using the parameterized query API that was already available.

For developers writing migration scripts, database seeders, or any infrastructure code that touches SQL: treat every value that isn't a hard-coded string literal as untrusted, and let your database driver handle the separation between code and data. That's the only reliable defense against SQL injection.


References

Frequently Asked Questions

What is SQL injection in migration scripts?

SQL injection in migration scripts occurs when user- or filesystem-controlled values (like filenames) are concatenated directly into SQL strings instead of being passed as bound parameters, allowing an attacker to alter the query's logic.

How do you prevent SQL injection in Node.js D1 scripts?

Use parameterized queries by passing a `params` array to Cloudflare's D1 query API instead of building SQL strings with template literals or concatenation.

What CWE is SQL injection?

SQL injection is classified as CWE-89: Improper Neutralization of Special Elements used in an SQL Command.

Is escaping single quotes enough to prevent SQL injection?

No. Escaping only single quotes (as `escapeSqlString` did) leaves many other injection vectors open, including comment sequences (`--`), semicolons, and multi-byte encoding tricks. Parameterized queries are the only reliable defense.

Can static analysis detect SQL injection in migration scripts?

Yes. Tools like Semgrep and AI-assisted scanners can trace tainted data from filesystem reads through string interpolation into SQL calls, which is exactly how this vulnerability was detected.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2

Related Articles

critical

How SQL Injection happens in Python database scripts and how to fix it

A critical SQL injection vulnerability was discovered in `MangosSuperUI/Scripts/discover_relationships.py`, where database, table, and column names were interpolated directly into SQL queries using Python f-strings. An attacker controlling these input parameters could execute arbitrary SQL against the database. The fix applies backtick escaping for identifier names and parameterized queries for the `LIMIT` clause.

critical

How SQL Injection happens in Node.js SQLite CLI calls and how to fix it

A critical SQL injection vulnerability was discovered in `lib/ParamediciOSPermissions.js`, where the `service` and `app` variables were interpolated directly into raw SQL strings passed to the `sqlite3` command-line tool without any escaping or parameterization. An attacker with control over these inputs could manipulate the iOS simulator's TCC permission database, potentially granting unauthorized app permissions. The fix applies SQLite-standard single-quote escaping to both variables before th

critical

How SQL Injection happens in Python SQLite utilities and how to fix it

A SQL injection risk was discovered in `scripts/db_utils.py` where the `_get_or_create` function used f-string interpolation to dynamically construct table and column names in SQL queries. While current callers passed hardcoded values, the function accepted arbitrary strings, making it a latent injection vector for any future code that passed user-controlled input. The fix replaces dynamic SQL construction with a strict allowlist of pre-written, parameterized query strings.

critical

How Unsafe Fall-Through in getWhereConditions Happens in Sequelize and How to Fix It

A critical vulnerability in Sequelize (CVE-2023-22579) allowed attackers to inject raw SQL through an unsafe fall-through in the `getWhereConditions` function when parentheses were used in query attributes. Upgrading from version 6.26.0 to 6.29.0 closes this attack vector by tightening how raw attributes are handled. Any Node.js application using Sequelize for database queries should treat this upgrade as an urgent security priority.

high

How SQL Injection happens in Python BigQuery connectors and how to fix it

A high-severity SQL injection vulnerability was discovered in a BigQuery connector's query-building logic, where Python f-strings interpolated user-controlled identifiers—project_id, dataset_id, table_id, and timestamp_column—directly into SQL without validation. An attacker with control over connector configuration could inject arbitrary BigQuery SQL, including destructive statements. The fix introduces strict allowlist-based identifier validation using compiled regular expressions before any S

high

How Quadratic CPU Consumption in js-yaml's !!omap Resolution Happens in Node.js and How to Fix It

A high-severity algorithmic complexity vulnerability (GHSA-5p4m-2wfm-xmqj) in js-yaml versions 3.x through 4.3.0 allows attackers to trigger quadratic CPU consumption through specially crafted `!!omap` YAML sequences. The fix upgrades js-yaml to 4.3.1 using a pnpm override in the `e2e/adapter/claude-code` package, ensuring all transitive dependencies also receive the patched version. This proactive patch eliminates an exploit primitive before it can be chained with other weaknesses.