GitHub Security Advisory: GHSA-wmrf-hv6w-mr66
Package: kysely (NPM)
Severity: HIGH (CVSS 8.2)
CWE: CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Vulnerable versions: >= 0.26.0, <= 0.28.11
Patched in: 0.28.12

Summary: SQL Injection via unsanitized JSON path keys when ignoring/silencing compilation errors or using `Kysely<any>`.

Details:
### Summary

Kysely through 0.28.11 has a SQL injection vulnerability in JSON path compilation for MySQL and SQLite dialects. The `visitJSONPathLeg()` function appends user-controlled values from `.key()` and `.at()` directly into single-quoted JSON path string literals (`'$.key'`) without escaping single quotes. An attacker can break out of the JSON path string context and inject arbitrary SQL.

This is inconsistent with `sanitizeIdentifier()`, which properly doubles delimiter characters for identifiers — both are non-parameterizable SQL constructs requiring manual escaping, but only identifiers are protected.

### Details

`visitJSONPath()` wraps JSON path in single quotes (`'$...'`), and `visitJSONPathLeg()` appends each key/index value via `this.append(String(node.value))` with no sanitization:

```javascript
// dist/cjs/query-compiler/default-query-compiler.js
visitJSONPath(node) {
    if (node.inOperator) {
        this.visitNode(node.inOperator);
    }
    this.append("'$");
    for (const pathLeg of node.pathLegs) {
        this.visitNode(pathLeg);        // Each leg appended without escaping
    }
    this.append("'");
}
visitJSONPathLeg(node) {
    const isArrayLocation = node.type === 'ArrayLocation';
    this.append(isArrayLocation ? '[' : '.');
    this.append(String(node.value));    // <-- NO single quote escaping
    if (isArrayLocation) {
        this.append(']');
    }
}
```

Contrast with `sanitizeIdentifier()` in the same file, which properly doubles delimiter characters:

```javascript
sanitizeIdentifier(identifier) {
    const leftWrap = this.getLeftIdentifierWrapper();
    const rightWrap = this.getRightIdentifierWrapper();
    let sanitized = '';
    for (const c of identifier) {
        sanitized += c;
        if (c === leftWrap) { sanitized += leftWrap; }
        else if (c === rightWrap) { sanitized += rightWrap; }
    }
    return sanitized;
}
```

Both identifiers and JSON path keys are non-parameterizable SQL constructs that require manual escaping. Identifiers are protected; JSON path values are not.

PostgreSQL is **not affected**. The branching happens in `JSONPathBuilder.#createBuilderWithPathLeg()` (`json-path-builder.js`):

- **MySQL/SQLite** operators (`->$`, `->>$`) produce a `JSONPathNode` traversal → `visitJSONPathLeg()` concatenates the key directly into a single-quoted JSON path string (`'$.key'`) — **vulnerable**, no escaping.
- **PostgreSQL** operators (`->`, `->>`) produce a `JSONOperatorChainNode` traversal → `ValueNode.createImmediate(value)` → `appendImmediateValue()` → `appendStringLiteral()` → **`sanitizeStringLiteral()` doubles single quotes** (`'` → `''`), generating chained operators (`"col"->>'city'`). Injection payload becomes a harmless string literal.

Same `.key()` call, different internal node creation depending on the operator type. The PostgreSQL path reuses the existing string literal sanitization; the MySQL/SQLite JSON path construction bypasses it entirely.

### PoC

End-to-end proof