VYPR
Critical severityNVD Advisory· Published Feb 22, 2023· Updated Mar 10, 2025

SQL Injection via replacements in sequelize

CVE-2023-25813

Description

Sequelize is a Node.js ORM tool. In versions prior to 6.19.1 a SQL injection exploit exists related to replacements. Parameters which are passed through replacements are not properly escaped which can lead to arbitrary SQL injection depending on the specific queries in use. The issue has been fixed in Sequelize 6.19.1. Users are advised to upgrade. Users unable to upgrade should not use the replacements and the where option in the same query.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Sequelize prior to 6.19.1 has a SQL injection vulnerability via unsanitized replacements, enabling arbitrary SQL execution.

Vulnerability

Description CVE-2023-25813 is a SQL injection vulnerability in Sequelize, a Node.js ORM, affecting versions prior to 6.19.1. The root cause is improper handling of replacements when combined with the where option. Sequelize first builds a query with the where clause, then substitutes :replacements via string replacement, but does not escape user input if it contains replacement syntax, leading to injection [1][3].

Exploitation

An attacker can exploit this by providing crafted input to the replacements parameter that includes replacement tokens (e.g., :firstName) in the where field. For example, passing {"firstName": "OR true; DROP TABLE users;", "lastName": ":firstName"} causes Sequelize to generate a query where the replacement is injected, allowing arbitrary SQL commands to be executed [3]. No authentication is required if the application exposes such queries to unauthenticated users.

Impact

Successful exploitation allows an attacker to execute arbitrary SQL queries against the database, potentially leading to data exfiltration, modification, or deletion. The severity is high due to the potential for complete database compromise [1].

Mitigation

The vulnerability is fixed in Sequelize 6.19.1, and users should upgrade immediately [2]. For those unable to upgrade, the workaround is to avoid using the replacements and where options in the same query [1]. The fix introduces a breaking change by using a SQL parser to ensure replacements are only injected where safe [3].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
sequelizenpm
< 6.19.16.19.1

Affected products

2

Patches

1
ccaa3996047f

fix: do not replace `:replacements` inside of strings (#14472)

21 files changed · +982 15
  • package.json+1 0 modified
    @@ -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",
    
  • src/dialects/abstract/index.d.ts+106 0 added
    @@ -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;
    +}
    
  • src/sequelize.js+3 6 modified
    @@ -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;
    
  • src/sql-string.d.ts+1 1 modified
    @@ -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;
    
  • src/utils.js+13 0 modified
    @@ -114,13 +114,26 @@ 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
       return SqlString.format(arr[0], arr.slice(1), timeZone, 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);
    
  • src/utils/sql.ts+221 0 added
    @@ -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;
    +}
    
  • test/integration/sequelize/query.test.js+6 6 modified
    @@ -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() {
    
  • test/support.js+6 2 modified
    @@ -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();
       }
    
  • test/unit/dialects/abstract/query-interface.test.d.ts+1 0 added
    @@ -0,0 +1 @@
    +export {};
    
  • test/unit/logger.test.d.ts+1 0 added
    @@ -0,0 +1 @@
    +export {};
    
  • test/unit/query-interface/bulk-delete.test.js+38 0 added
    @@ -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\';'
    +    });
    +  });
    +});
    
  • test/unit/query-interface/bulk-insert.test.js+32 0 added
    @@ -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\');'
    +    });
    +  });
    +});
    
  • test/unit/query-interface/decrement.test.js+45 0 added
    @@ -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\''
    +    });
    +  });
    +});
    
  • test/unit/query-interface/delete.test.js+38 0 added
    @@ -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\';'
    +    });
    +  });
    +});
    
  • test/unit/query-interface/increment.test.js+45 0 added
    @@ -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\''
    +    });
    +  });
    +});
    
  • test/unit/query-interface/raw-select.test.js+35 0 added
    @@ -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\';'
    +    });
    +  });
    +});
    
  • test/unit/query-interface/select.test.js+35 0 added
    @@ -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\';'
    +    });
    +  });
    +});
    
  • test/unit/query-interface/stub-query-run.js+22 0 added
    @@ -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;
    +  };
    +};
    
  • test/unit/sequelize.test.d.ts+1 0 added
    @@ -0,0 +1 @@
    +export {};
    
  • test/unit/utils/sql.test.js+327 0 added
    @@ -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');`
    +    });
    +  });
    +});
    
  • yarn.lock+5 0 modified
    @@ -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"
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.