Security advisory GHSA-wrh9-cjv3-2hpw: Sequelize vulnerable to SQL Injection via replacements
Package: sequelize (npm), vulnerable version: 6.19.0, fixed in: 6.19.1
CWE: CWE-89
Files changed by the fix:
src/dialects/abstract/index.d.ts
src/sequelize.js
src/sql-string.d.ts
src/utils.js
src/utils/sql.ts

Fix diff:
diff --git a/package.json b/package.json
index 08e3cb857c87..2d37e64aec2e 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
     "@octokit/rest": "^18.12.0",
     "@octokit/types": "^6.34.0",
     "@types/chai": "^4.3.0",
+    "@types/lodash": "4.14.182",
     "@types/mocha": "^9.0.0",
     "@types/node": "^16.11.17",
     "@types/sinon": "^10.0.6",
diff --git a/src/dialects/abstract/index.d.ts b/src/dialects/abstract/index.d.ts
new file mode 100644
index 000000000000..018463fec091
--- /dev/null
+++ b/src/dialects/abstract/index.d.ts
@@ -0,0 +1,106 @@
+import type { Dialect } from '../../sequelize.js';
+import type { AbstractQuery } from './query.js';
+
+export declare type DialectSupports = {
+  'DEFAULT': boolean;
+  'DEFAULT VALUES': boolean;
+  'VALUES ()': boolean;
+  'LIMIT ON UPDATE': boolean;
+  'ON DUPLICATE KEY': boolean;
+  'ORDER NULLS': boolean;
+  'UNION': boolean;
+  'UNION ALL': boolean;
+  'RIGHT JOIN': boolean;
+  EXCEPTION: boolean;
+  forShare?: 'LOCK IN SHARE MODE' | 'FOR SHARE' | undefined;
+  lock: boolean;
+  lockOf: boolean;
+  lockKey: boolean;
+  lockOuterJoinFailure: boolean;
+  skipLocked: boolean;
+  finalTable: boolean;
+  returnValues: false | {
+    output: boolean;
+    returning: boolean;
+  };
+  autoIncrement: {
+    identityInsert: boolean;
+    defaultValue: boolean;
+    update: boolean;
+  };
+  bulkDefault: boolean;
+  schemas: boolean;
+  transactions: boolean;
+  settingIsolationLevelDuringTransaction: boolean;
+  transactionOptions: {
+    type: boolean;
+  };
+  migrations: boolean;
+  upserts: boolean;
+  inserts: {
+    ignoreDuplicates: string;
+    updateOnDuplicate: boolean | string;
+    onConflictDoNothing: string;
+    conflictFields: boolean;
+  };
+  constraints: {
+    restrict: boolean;
+    addConstraint: boolean;
+    dropConstraint: boolean;
+    unique: boolean;
+    default: boolean;
+    check: boolean;
+    foreignKey: boolean;
+    primaryKey: boolean;
+    onUpdate: boolean;
+  };
+  index: {
+    collate: boolean;
+    length: boolean;
+    parser: boolean;
+    concurrently: boolean;
+    type: boolean;
+    using: boolean | number;
+    functionBased: boolean;
+    operator: boolean;
+    where: boolean;
+  };
+  groupedLimit: boolean;
+  indexViaAlter: boolean;
+  JSON: boolean;
+  JSONB: boolean;
+  ARRAY: boolean;
+  RANGE: boolean;
+  NUMERIC: boolean;
+  GEOMETRY: boolean;
+  GEOGRAPHY: boolean;
+  REGEXP: boolean;
+  /**
+   * Case-insensitive regexp operator support ('~*' in postgres).
+   */
+  IREGEXP: boolean;
+  HSTORE: boolean;
+  TSVECTOR: boolean;
+  deferrableConstraints: boolean;
+  tmpTableTrigger: boolean;
+  indexHints: boolean;
+  searchPath: boolean;
+};
+
+export declare abstract class AbstractDialect {
+  /**
+   * List of features this dialect supports.
+   *
+   * Important: Dialect implementations inherit these values.
+   * When changing a default, ensure the implementations still properly declare which feature they support.
+   */
+  static readonly supports: DialectSupports;
+  readonly defaultVersion: string;
+  readonly Query: typeof AbstractQuery;
+  readonly name: Dialect;
+  readonly TICK_CHAR: string;
+  readonly TICK_CHAR_LEFT: string;
+  readonly TICK_CHAR_RIGHT: string;
+  readonly queryGenerator: unknown;
+  get supports(): DialectSupports;
+}
diff --git a/src/sequelize.js b/src/sequelize.js
index 4e21665d0489..e424a9a2b2d2 100644
--- a/src/sequelize.js
+++ b/src/sequelize.js
@@ -26,6 +26,7 @@ const { BelongsTo } = require('./associations/belongs-to');
 const HasOne = require('./associations/has-one');
 const { BelongsToMany } = require('./associations/belongs-to-many');
 const { HasMany } = require('./associations/has-many');
+const { injectReplacements } = require('./utils/sql');
 
 /**
  * This is the main class, the entry point to sequelize.
@@ -598,11 +599,7 @@ class Sequelize {
     }
 
     if (options.replacements) {
-      if (Array.isArray(options.replacements)) {
-        sql = Utils.format([sql].concat(options.replacements), this.options.dialect);
-      } else {
-        sql = Utils.formatNamedParameters(sql, options.replacements, this.options.dialect);
-      }
+      sql = injectReplacements(sql, this.dialect, options.replacements);
     }
 
     let bindParameters;
@@ -629,7 +626,7 @@ class Sequelize {
       checkTransaction();
 
       const connection = await (options.transaction ? options.transaction.connection : this.connectionManager.getConnection(options));
-      
+
       if (this.options.dialect === 'db2' && options.alter) {
         if (options.alter.drop === false) {
           connection.dropTable = false;
diff --git a/src/sql-string.d.ts b/src/sql-string.d.ts
index bf2d068acd98..310402495179 100644
--- a/src/sql-string.d.ts
+++ b/src/sql-string.d.ts
@@ -1,5 +1,5 @@
 export type Escapable = undefined | null | boolean | number | string | Date;
 export function escapeId(val: string, forbidQualified?: boolean): string;
-export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: string): string;
+export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: boolean): string;
 export function format(sql: string, values: unknown[], timeZone?: string, dialect?: string): string;
 export function formatNamedParameters(sql: string, values: unknown[], timeZone?: string, dialect?: string): string;
diff --git a/src/utils.js b/src/utils.js
index 703fd7d0b628..739dbcfd2232 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -114,6 +114,12 @@ function pluralize(str) {
 }
 exports.pluralize = pluralize;
 
+/**
+ * @deprecated use {@link injectReplacements} instead. This method has been removed in v7.
+ *
+ * @param {[string, ...unknown[]]} arr - first item is the SQL, following items are the positional replacements.
+ * @param {AbstractDialect} dialect
+ */
 function format(arr, dialect) {
   const timeZone = null;
   // Make a clone of the array beacuse format modifies the passed args
@@ -121,6 +127,13 @@ function format(arr, dialect) {
 }
 exports.format = format;
 
+/**
+ * @deprecated use {@link injectReplacements} instead. This method has been removed in v7.
+ *
+ * @param {string} sql
+ * @param {object} parameters
+ * @param {AbstractDialect} dialect
+ */
 function formatNamedParameters(sql, parameters, dialect) {
   const timeZone = null;
   return SqlString.formatNamedParameters(sql, parameters, timeZone, dialect);
diff --git a/src/utils/sql.ts b/src/utils/sql.ts
new file mode 100644
index 000000000000..c252cd2704ea
--- /dev/null
+++ b/src/utils/sql.ts
@@ -0,0 +1,221 @@
+import isPlainObject from 'lodash/isPlainObject';
+import type { AbstractDialect } from '../dialects/abstract/index.js';
+import { escape as escapeSqlValue } from '../sql-string';
+
+type BindOrReplacements = { [key: string]: unknown } | unknown[];
+
+/**
+ * Inlines replacements in places where they would be valid SQL values.
+ *
+ * @param sqlString The SQL that contains the replacements
+ * @param dialect The dialect of the SQL
+ * @param replacements if provided, this method will replace ':named' replacements & positional replacements (?)
+ *
+ * @returns The SQL with replacements rewritten in their dialect-specific syntax.
+ */
+export function injectReplacements(
+  sqlString: string,
+  dialect: AbstractDialect,
+  replacements: BindOrReplacements
+): string {
+  if (replacements == null) {
+    return sqlString;
+  }
+
+  if (!Array.isArray(replacements) && !isPlainObject(replacements)) {
+    throw new TypeError(`"replacements" must be an array or a plain object, but received ${JSON.stringify(replacements)} instead.`);
+  }
+
+  const isNamedReplacements = isPlainObject(replacements);
+  const isPositionalReplacements = Array.isArray(replacements);
+  let lastConsumedPositionalReplacementIndex = -1;
+
+  let output = '';
+
+  let currentDollarStringTagName = null;
+  let isString = false;
+  let isColumn = false;
+  let previousSliceEnd = 0;
+  let isSingleLineComment = false;
+  let isCommentBlock = false;
+
+  for (let i = 0; i < sqlString.length; i++) {
+    const char = sqlString[i];
+
+    if (isColumn) {
+      if (char === dialect.TICK_CHAR_RIGHT) {
+        isColumn = false;
+      }
+
+      continue;
+    }
+
+    if (isString) {
+      if (char === '\'' && !isBackslashEscaped(sqlString, i - 1)) {
+        isString = false;
+      }
+
+      continue;
+    }
+
+    if (currentDollarStringTagName !== null) {
+      if (char !== '$') {
+        continue;
+      }
+
+      const remainingString = sqlString.slice(i, sqlString.length);
+
+      const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_])?(\$)/i);
+      const tagName = dollarStringStartMatch?.groups?.name;
+      if (currentDollarStringTagName === tagName) {
+        currentDollarStringTagName = null;
+      }
+
+      continue;
+    }
+
+    if (isSingleLineComment) {
+      if (char === '\n') {
+        isSingleLineComment = false;
+      }
+
+      continue;
+    }
+
+    if (isCommentBlock) {
+      if (char === '*' && sqlString[i + 1] === '/') {
+        isCommentBlock = false;
+      }
+
+      continue;
+    }
+
+    if (char === dialect.TICK_CHAR_LEFT) {
+      isColumn = true;
+      continue;
+    }
+
+    if (char === '\'') {
+      isString = true;
+      continue;
+    }
+
+    if (char === '-' && sqlString.slice(i, i + 3) === '-- ') {
+      isSingleLineComment = true;
+      continue;
+    }
+
+    if (char === '/' && sqlString.slice(i, i + 2) === '/*') {
+      isCommentBlock = true;
+      continue;
+    }
+
+    // either the start of a $bind parameter, or the start of a $tag$string$tag$
+    if (char === '$') {
+      const previousChar = sqlString[i - 1];
+
+      // we are part of an identifier
+      if (/[0-9a-z_]/i.test(previousChar)) {
+        continue;
+      }
+
+      const remainingString = sqlString.slice(i, sqlString.length);
+
+      const dollarStringStartMatch = remainingString.match(/^\$(?<name>[a-z_][0-9a-z_]*)?\$/i);
+      if (dollarStringStartMatch) {
+        currentDollarStringTagName = dollarStringStartMatch.groups?.name ?? '';
+
+        continue;
+      }
+
+      continue;
+    }
+
+    if (isNamedReplacements && char === ':') {
+      const previousChar = sqlString[i - 1];
+      // we want to be conservative with what we consider to be a replacement to avoid risk of conflict with potential operators
+      // users need to add a space before the bind parameter (except after '(', ',', and '=')
+      if (previousChar !== undefined && !/[\s(,=]/.test(previousChar)) {
+        continue;
+      }
+
+      const remainingString = sqlString.slice(i, sqlString.length);
+
+      const match = remainingString.match(/^:(?<name>[a-z_][0-9a-z_]*)(?:\)|,|$|\s|::)/i);
+      const replacementName = match?.groups?.name;
+      if (!replacementName) {
+        continue;
+      }
+
+      // @ts-expect-error -- isPlainObject does not tell typescript that replacements is a plain object, not an array
+      const replacementValue = replacements[replacementName];
+      if (!Object.prototype.hasOwnProperty.call(replacements, replacementName) || replacementValue === undefined) {
+        throw new Error(`Named replacement ":${replacementName}" has no entry in the replacement map.`);
+      }
+
+      const escapedReplacement = escapeSqlValue(replacementValue, undefined, dialect.name, true);
+
+      // add everything before the bind parameter name
+      output += sqlString.slice(previousSliceEnd, i);
+      // continue after the bind parameter name
+      previousSliceEnd = i + replacementName.length + 1;
+
+      output += escapedReplacement;
+
+      continue;
+    }
+
+    if (isPositionalReplacements && char === '?') {
+      const previousChar = sqlString[i - 1];
+
+      // we want to be conservative with what we consider to be a replacement to avoid risk of conflict with potential operators
+      // users need to add a space before the bind parameter (except after '(', ',', and '=')
+      if (previousChar !== undefined && !/[\s(,=]/.test(previousChar)) {
+        continue;
+      }
+
+      // don't parse ?| and ?& operators as replacements
+      const nextChar = sqlString[i + 1];
+      if (nextChar === '|' || nextChar === '&') {
+        continue;
+      }
+
+      const replacementIndex = ++lastConsumedPositionalReplacementIndex;
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore -- ts < 4.4 loses the information that 'replacements' is an array when using 'isPositionalReplacements' instead of 'Array.isArray'
+      //  but performance matters here.
+      const replacementValue = replacements[lastConsumedPositionalReplacementIndex];
+
+      if (replacementValue === undefined) {
+        throw new Error(`Positional replacement (?) ${replacementIndex} has no entry in the replacement map (replacements[${replacementIndex}] is undefined).`);
+      }
+
+      const escapedReplacement = escapeSqlValue(replacementValue as any, undefined, dialect.name, true);
+
+      // add everything before the bind parameter name
+      output += sqlString.slice(previousSliceEnd, i);
+      // continue after the bind parameter name
+      previousSliceEnd = i + 1;
+
+      output += escapedReplacement;
+    }
+  }
+
+  output += sqlString.slice(previousSliceEnd, sqlString.length);
+
+  return output;
+}
+
+function isBackslashEscaped(string: string, pos: number): boolean {
+  let escaped = false;
+  for (let i = pos; i >= 0; i--) {
+    const char = string[i];
+    if (char !== '\\') {
+      break;
+    }
+
+    escaped = !escaped;
+  }
+
+  return escaped;
+}
diff --git a/test/integration/sequelize/query.test.js b/test/integration/sequelize/query.test.js
index 1cc9ad76af09..767940b5dbe9 100644
--- a/test/integration/sequelize/query.test.js
+++ b/test/integration/sequelize/query.test.js
@@ -457,27 +457,27 @@ describe(Support.getTestDialectTeaser('Sequelize'), () => {
 
       it('reject when key is missing in the passed object', async function() {
         await this.sequelize.query('select :one as foo, :two as bar, :three as baz', { raw: true, replacements: { one: 1, two: 2 } })
-          .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g);
+          .should.be.rejectedWith(Error, 'Named replacement ":three" has no entry in the replacement map.');
       });
 
       it('reject with the passed number', async function() {
         await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: 2 })
-          .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g);
+          .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received 2 instead.');
       });
 
       it('reject with the passed empty object', async function() {
         await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: {} })
-          .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g);
+          .should.be.rejectedWith(Error, 'Named replacement ":one" has no entry in the replacement map.');
       });
 
       it('reject with the passed string', async function() {
         await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: 'foobar' })
-          .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g);
+          .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received "foobar" instead.');
       });
 
       it('reject with the passed date', async function() {
-        await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: new Date() })
-          .should.be.rejectedWith(Error, /Named parameter ":\w+" has no value in the given object\./g);
+        await this.sequelize.query('select :one as foo, :two as bar', { raw: true, replacements: new Buffer([1]) })
+          .should.be.rejectedWith(Error, '"replacements" must be an array or a plain object, but received {"type":"Buffer","data":[1]} instead.');
       });
 
       it('reject when binds passed with object and numeric $1 is also present', async function() {
diff --git a/test/support.js b/test/support.js
index 34e6c9c7dfcd..fe621ec9a909 100644
--- a/test/support.js
+++ b/test/support.js
@@ -241,7 +241,7 @@ const Support = {
     if (query instanceof Error) {
       expect(query.message).to.equal(expectation.message);
     } else {
-      expect(query.query || query).to.equal(expectation);
+      expect(Support.minifySql(query.query || query)).to.equal(Support.minifySql(expectation));
     }
 
     if (assertions.bind) {
@@ -267,8 +267,12 @@ const Support = {
   minifySql(sql) {
     // replace all consecutive whitespaces with a single plain space character
     return sql.replace(/\s+/g, ' ')
-      // remove space before coma
+      // remove space before comma
       .replace(/ ,/g, ',')
+      // remove space before )
+      .replace(/ \)/g, ')')
+      // replace space after (
+      .replace(/\( /g, '(')
       // remove whitespace at start & end
       .trim();
   }
diff --git a/test/unit/dialects/abstract/query-interface.test.d.ts b/test/unit/dialects/abstract/query-interface.test.d.ts
new file mode 100644
index 000000000000..cb0ff5c3b541
--- /dev/null
+++ b/test/unit/dialects/abstract/query-interface.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/test/unit/logger.test.d.ts b/test/unit/logger.test.d.ts
new file mode 100644
index 000000000000..cb0ff5c3b541
--- /dev/null
+++ b/test/unit/logger.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/test/unit/query-interface/bulk-delete.test.js b/test/unit/query-interface/bulk-delete.test.js
new file mode 100644
index 000000000000..7d2449bda3c6
--- /dev/null
+++ b/test/unit/query-interface/bulk-delete.test.js
@@ -0,0 +1,38 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#bulkDelete', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse replacements outside of raw sql', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().bulkDelete(
+      User.tableName,
+      { id: ':id' },
+      {
+        logging: console.log,
+        replacements: {
+          limit: 1,
+          id: '123'
+        }
+      },
+      User
+    );
+
+    expectsql(getSql(), {
+      default: 'DELETE FROM [Users] WHERE [id] = \':id\'',
+      mssql: 'DELETE FROM [Users] WHERE [id] = N\':id\'; SELECT @@ROWCOUNT AS AFFECTEDROWS;',
+      snowflake: 'DELETE FROM "Users" WHERE "id" = \':id\';'
+    });
+  });
+});
diff --git a/test/unit/query-interface/bulk-insert.test.js b/test/unit/query-interface/bulk-insert.test.js
new file mode 100644
index 000000000000..6ab4aa7dc405
--- /dev/null
+++ b/test/unit/query-interface/bulk-insert.test.js
@@ -0,0 +1,32 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#bulkInsert', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse replacements outside of raw sql', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().bulkInsert(User.tableName, [{
+      firstName: ':injection'
+    }], {
+      replacements: {
+        injection: 'raw sql'
+      }
+    });
+
+    expectsql(getSql(), {
+      default: 'INSERT INTO [Users] ([firstName]) VALUES (\':injection\');',
+      mssql: 'INSERT INTO [Users] ([firstName]) VALUES (N\':injection\');'
+    });
+  });
+});
diff --git a/test/unit/query-interface/decrement.test.js b/test/unit/query-interface/decrement.test.js
new file mode 100644
index 000000000000..8a0cb1cba311
--- /dev/null
+++ b/test/unit/query-interface/decrement.test.js
@@ -0,0 +1,45 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#decrement', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse replacements outside of raw sql', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().decrement(
+      User,
+      User.tableName,
+      // where
+      { id: ':id' },
+      // incrementAmountsByField
+      { age: ':age' },
+      // extraAttributesToBeUpdated
+      { name: ':name' },
+      // options
+      {
+        returning: [':data'],
+        replacements: {
+          age: 1,
+          id: 2,
+          data: 3
+        }
+      }
+    );
+
+    expectsql(getSql(), {
+      default: 'UPDATE [Users] SET [age]=[age]- \':age\',[name]=\':name\' WHERE [id] = \':id\'',
+      postgres: 'UPDATE "Users" SET "age"="age"- \':age\',"name"=\':name\' WHERE "id" = \':id\' RETURNING ":data"',
+      mssql: 'UPDATE [Users] SET [age]=[age]- N\':age\',[name]=N\':name\' OUTPUT INSERTED.[:data] WHERE [id] = N\':id\''
+    });
+  });
+});
diff --git a/test/unit/query-interface/delete.test.js b/test/unit/query-interface/delete.test.js
new file mode 100644
index 000000000000..68469dee8dd4
--- /dev/null
+++ b/test/unit/query-interface/delete.test.js
@@ -0,0 +1,38 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#delete', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse replacements outside of raw sql', async () => {
+    const getSql = stubQueryRun();
+    const instance = new User();
+
+    await sequelize.getQueryInterface().delete(
+      instance,
+      User.tableName,
+      { id: ':id' },
+      {
+        replacements: {
+          limit: 1,
+          id: '123'
+        }
+      }
+    );
+
+    expectsql(getSql(), {
+      default: 'DELETE FROM [Users] WHERE [id] = \':id\'',
+      mssql: 'DELETE FROM [Users] WHERE [id] = N\':id\'; SELECT @@ROWCOUNT AS AFFECTEDROWS;',
+      snowflake: 'DELETE FROM "Users" WHERE "id" = \':id\';'
+    });
+  });
+});
diff --git a/test/unit/query-interface/increment.test.js b/test/unit/query-interface/increment.test.js
new file mode 100644
index 000000000000..05ab9bedb726
--- /dev/null
+++ b/test/unit/query-interface/increment.test.js
@@ -0,0 +1,45 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#increment', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse replacements outside of raw sql', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().increment(
+      User,
+      User.tableName,
+      // where
+      { id: ':id' },
+      // incrementAmountsByField
+      { age: ':age' },
+      // extraAttributesToBeUpdated
+      { name: ':name' },
+      // options
+      {
+        returning: [':data'],
+        replacements: {
+          age: 1,
+          id: 2,
+          data: 3
+        }
+      }
+    );
+
+    expectsql(getSql(), {
+      default: 'UPDATE [Users] SET [age]=[age]+ \':age\',[name]=\':name\' WHERE [id] = \':id\'',
+      postgres: 'UPDATE "Users" SET "age"="age"+ \':age\',"name"=\':name\' WHERE "id" = \':id\' RETURNING ":data"',
+      mssql: 'UPDATE [Users] SET [age]=[age]+ N\':age\',[name]=N\':name\' OUTPUT INSERTED.[:data] WHERE [id] = N\':id\''
+    });
+  });
+});
diff --git a/test/unit/query-interface/raw-select.test.js b/test/unit/query-interface/raw-select.test.js
new file mode 100644
index 000000000000..8404ef27bf85
--- /dev/null
+++ b/test/unit/query-interface/raw-select.test.js
@@ -0,0 +1,35 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#rawSelect', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse user-provided data as replacements', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().rawSelect(User.tableName, {
+      // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript
+      attributes: ['id'],
+      where: {
+        username: 'some :data'
+      },
+      replacements: {
+        data: 'OR \' = '
+      }
+    }, 'id', User);
+
+    expectsql(getSql(), {
+      default: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = \'some :data\';',
+      mssql: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = N\'some :data\';'
+    });
+  });
+});
diff --git a/test/unit/query-interface/select.test.js b/test/unit/query-interface/select.test.js
new file mode 100644
index 000000000000..4e04109a3cf9
--- /dev/null
+++ b/test/unit/query-interface/select.test.js
@@ -0,0 +1,35 @@
+const { DataTypes } = require('sequelize');
+const sinon = require('sinon');
+const { expectsql, sequelize } = require('../../support');
+const { stubQueryRun } = require('./stub-query-run');
+
+describe('QueryInterface#select', () => {
+  const User = sequelize.define('User', {
+    firstName: DataTypes.STRING
+  }, { timestamps: false });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  // you'll find more replacement tests in query-generator tests
+  it('does not parse user-provided data as replacements', async () => {
+    const getSql = stubQueryRun();
+
+    await sequelize.getQueryInterface().select(User, User.tableName, {
+      // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript
+      attributes: ['id'],
+      where: {
+        username: 'some :data'
+      },
+      replacements: {
+        data: 'OR \' = '
+      }
+    });
+
+    expectsql(getSql(), {
+      default: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = \'some :data\';',
+      mssql: 'SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = N\'some :data\';'
+    });
+  });
+});
diff --git a/test/unit/query-interface/stub-query-run.js b/test/unit/query-interface/stub-query-run.js
new file mode 100644
index 000000000000..faa36779fbd9
--- /dev/null
+++ b/test/unit/query-interface/stub-query-run.js
@@ -0,0 +1,22 @@
+const sinon = require('sinon');
+const { sequelize } = require('../../support');
+
+module.exports.stubQueryRun = function stubQueryRun() {
+  let lastExecutedSql;
+
+  class FakeQuery {
+    run(sql) {
+      lastExecutedSql = sql;
+
+      return [];
+    }
+  }
+
+  sinon.stub(sequelize.dialect, 'Query').get(() => FakeQuery);
+  sinon.stub(sequelize.connectionManager, 'getConnection').returns({});
+  sinon.stub(sequelize.connectionManager, 'releaseConnection');
+
+  return () => {
+    return lastExecutedSql;
+  };
+};
diff --git a/test/unit/sequelize.test.d.ts b/test/unit/sequelize.test.d.ts
new file mode 100644
index 000000000000..cb0ff5c3b541
--- /dev/null
+++ b/test/unit/sequelize.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/test/unit/utils/sql.test.js b/test/unit/utils/sql.test.js
new file mode 100644
index 000000000000..eea503679cd6
--- /dev/null
+++ b/test/unit/utils/sql.test.js
@@ -0,0 +1,327 @@
+const { injectReplacements } = require('sequelize/lib/utils/sql');
+const { expect } = require('chai');
+const { expectsql, sequelize } = require('../../support');
+
+const dialect = sequelize.dialect;
+
+describe('injectReplacements (named replacements)', () => {
+  it('parses named replacements', () => {
+    const sql = injectReplacements(`SELECT ${dialect.TICK_CHAR_LEFT}:id${dialect.TICK_CHAR_RIGHT} FROM users WHERE id = ':id' OR id = :id OR id = ''':id'''`, dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT [:id] FROM users WHERE id = \':id\' OR id = 1 OR id = \'\'\':id\'\'\''
+    });
+  });
+
+  it('throws if a named replacement is not provided as an own property', () => {
+    expect(() => {
+      injectReplacements('SELECT * FROM users WHERE id = :toString', dialect, {
+        id: 1
+      });
+    }).to.throw('Named replacement ":toString" has no entry in the replacement map.');
+
+  });
+
+  it('parses named replacements followed by cast syntax', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = :id::string', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = 1::string'
+    });
+  });
+
+  it('parses single letter named replacements', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = :a', dialect, {
+      a: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it does not follow \'(\', \',\', \'=\' or whitespace', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = fn(:id) OR id = fn(\'a\',:id) OR id=:id OR id = :id', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = fn(1) OR id = fn(\'a\',1) OR id=1 OR id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a $ quoted string', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = $tag$ :id $tag$ OR id = $$ :id $$', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = $tag$ :id $tag$ OR id = $$ :id $$'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a nested $ quoted string', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = $tag1$ $tag2$ :id $tag2$ $tag1$', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = $tag1$ $tag2$ :id $tag2$ $tag1$'
+    });
+  });
+
+  it('does consider the token to be a replacement if it is in between two identifiers that look like $ quoted strings', () => {
+    const sql = injectReplacements('SELECT z$$ :id x$$ * FROM users', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT z$$ 1 x$$ * FROM users'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a string with a backslash escaped quote', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\':id\' OR id = :id', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\':id\' OR id = 1'
+    });
+  });
+
+  it('considers the token to be a replacement if it is outside a string ending with an escaped backslash', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\' OR id = :id', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\\\' OR id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a string with an escaped backslash followed by a backslash escaped quote', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\\\':id\' OR id = :id', dialect, {
+      id: 1
+    });
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\\\\\':id\' OR id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in a single line comment', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users -- WHERE id = :id
+      WHERE id = :id
+    `, dialect, { id: 1 });
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users -- WHERE id = :id
+        WHERE id = 1
+      `
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in string but a previous comment included a string delimiter', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users -- '
+      WHERE id = ' :id '
+    `, dialect, { id: 1 });
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users -- '
+        WHERE id = ' :id '
+      `
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in a single line comment', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users /*
+      WHERE id = :id
+      */
+      WHERE id = :id
+    `, dialect, { id: 1 });
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users /*
+        WHERE id = :id
+        */
+        WHERE id = 1
+      `
+    });
+  });
+
+  it('does not interpret ::x as a replacement, as it is a cast', () => {
+    expect(injectReplacements('(\'foo\')::string', dialect, [0])).to.equal('(\'foo\')::string');
+  });
+});
+
+describe('injectReplacements (positional replacements)', () => {
+  it('parses positional replacements', () => {
+    const sql = injectReplacements(`SELECT ${dialect.TICK_CHAR_LEFT}?${dialect.TICK_CHAR_RIGHT} FROM users WHERE id = '?' OR id = ? OR id = '''?''' OR id2 = ?`, dialect, [1, 2]);
+
+    expectsql(sql, {
+      default: 'SELECT [?] FROM users WHERE id = \'?\' OR id = 1 OR id = \'\'\'?\'\'\' OR id2 = 2'
+    });
+  });
+
+  it('parses positional replacements followed by cast syntax', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = ?::string', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = 1::string'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it does not follow \'(\', \',\', \'=\' or whitespace', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = fn(?) OR id = fn(\'a\',?) OR id=? OR id = ?', dialect, [2, 1, 3, 4]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = fn(2) OR id = fn(\'a\',1) OR id=3 OR id = 4'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a $ quoted string', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = $tag$ ? $tag$ OR id = $$ ? $$', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = $tag$ ? $tag$ OR id = $$ ? $$'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a nested $ quoted string', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = $tag1$ $tag2$ ? $tag2$ $tag1$', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = $tag1$ $tag2$ ? $tag2$ $tag1$'
+    });
+  });
+
+  it('does consider the token to be a replacement if it is in between two identifiers that look like $ quoted strings', () => {
+    const sql = injectReplacements('SELECT z$$ ? x$$ * FROM users', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT z$$ 1 x$$ * FROM users'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a string with a backslash escaped quote', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\'?\' OR id = ?', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\'?\' OR id = 1'
+    });
+  });
+
+  it('considers the token to be a replacement if it is outside a string ending with an escaped backslash', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\' OR id = ?', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\\\' OR id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is part of a string with an escaped backslash followed by a backslash escaped quote', () => {
+    const sql = injectReplacements('SELECT * FROM users WHERE id = \'\\\\\\\'?\' OR id = ?', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM users WHERE id = \'\\\\\\\'?\' OR id = 1'
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in a single line comment', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users -- WHERE id = ?
+      WHERE id = ?
+    `, dialect, [1]);
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users -- WHERE id = ?
+        WHERE id = 1
+      `
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in string but a previous comment included a string delimiter', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users -- '
+      WHERE id = ' ? '
+    `, dialect, [1]);
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users -- '
+        WHERE id = ' ? '
+      `
+    });
+  });
+
+  it('does not consider the token to be a replacement if it is in a single line comment', () => {
+    const sql = injectReplacements(`
+      SELECT * FROM users /*
+      WHERE id = ?
+      */
+      WHERE id = ?
+    `, dialect, [1]);
+
+    expectsql(sql, {
+      default: `
+        SELECT * FROM users /*
+        WHERE id = ?
+        */
+        WHERE id = 1
+      `
+    });
+  });
+
+  // https://github.com/sequelize/sequelize/issues/14358
+  it('does not parse ?& and ?| operators as replacements (#14358)', async () => {
+    const sql = injectReplacements('SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = ?;', dialect, [1]);
+
+    expectsql(sql, {
+      default: 'SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = 1;',
+      // 'default' removes the trailing ; for ibmi, but we actually need to test it's there this time, to ensure '?;' is treated as a replacement + ';'
+      ibmi: 'SELECT * FROM products WHERE tags ?& ARRAY[1] AND tags ?| ARRAY[1] AND id = 1;'
+    });
+  });
+
+  it('formats where clause correctly when the value is falsy', () => {
+    expect(injectReplacements('foo = ?', dialect, [0])).to.equal('foo = 0');
+  });
+
+  it('formats arrays as an expression instead of an ARRAY data type', async () => {
+    const sql = injectReplacements('INSERT INTO users (username, email, created_at, updated_at) VALUES ?;', dialect, [[
+      ['john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'],
+      ['michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10']
+    ]]);
+
+    expectsql(sql, {
+      default: `
+        INSERT INTO users (username, email, created_at, updated_at)
+        VALUES
+          ('john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'),
+          ('michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10');`,
+      // 'default' removes the trailing ; for ibmi, but we actually need to test it's there this time, to ensure '?;' is treated as a replacement + ';'
+      ibmi: `
+        INSERT INTO users (username, email, created_at, updated_at)
+        VALUES
+          ('john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'),
+          ('michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10');`,
+      mssql: `
+        INSERT INTO users (username, email, created_at, updated_at)
+        VALUES
+          (N'john', N'john@gmail.com', N'2012-01-01 10:10:10', N'2012-01-01 10:10:10'),
+          (N'michael', N'michael@gmail.com', N'2012-01-01 10:10:10', N'2012-01-01 10:10:10');`
+    });
+  });
+});
diff --git a/yarn.lock b/yarn.lock
index 7ec00d439fcd..514efe1607bd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -990,6 +990,11 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
   integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
 
+"@types/lodash@4.14.182":
+  version "4.14.182"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
+  integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
+
 "@types/minimist@^1.2.0":
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"