JSON-Path Traversal Injection in Kysely A Case Study Powered by ClaudeCode

I. Introduction
Kysely is an open-source TypeScript SQL query builder. It lets developers build type-safe SQL queries SELECT, INSERT, UPDATE, DELETE, joins, JSON traversal directly in TypeScript, with the column and table types checked at compile time.
It is one of the most widely adopted query builders in the Node.js ecosystem (kysely on npm), used as the data-access layer in a large number of production backends across PostgreSQL, MySQL, and SQLite.
Vulnerable Version: Kysely v0.26.0 – v0.28.16 (fixed in v0.28.17)
Advisory: https://github.com/kysely-org/kysely/security/advisories/GHSA-pv5w-4p9q-p3v2
CVE: CVE-2026-44635 — High, CVSS 3.1 7.5
II. Target selection
I was assigned the task of using AI to find bugs in open-source projects. After browsing GitHub and npm for a while, I landed on Kysely.
From a few basic pieces of information it looked like a promising target: it is a query builder written in TypeScript that ultimately emits raw SQL strings, and it has a JSON-traversal feature (.key() / .at()) where developer- or user-supplied keys flow into the generated SQL. Anywhere a library turns input into SQL text is worth a close look. It was also more interesting than a typical web app a bug here is a library bug, so it potentially affects every application built on top of it.
III. Finding
After cloning the repository I immediately proceeded to prompt for bug hunting. Here I used:
Claude Code (Claude CLI)
Model: Claude Opus 4.7
I started by pointing the model at the JSON-traversal feature and at the recent security history of the project. It quickly surfaced something interesting: commit 0a602bf (PR #1727) had recently patched a previous JSON-path injection issue (GHSA-wmrf-hv6w-mr66 / CVE-2026-32763). The patch was very small it only added a single sanitizeStringLiteral() call. That is a classic incomplete-fix smell, so I decided to focus the audit there instead of chasing RCE-style bugs that mostly turned into false positives.
I used the following prompt (I also used AI to generate this prompt based on the application's source code and functions):
You are a senior offensive security researcher specialized in TypeScript / Node.js, SQL query builders, ORM internals, JSON-path query languages (MySQL JSON_EXTRACT, PostgreSQL jsonpath, SQLite json_extract), and injection discovery.
Your task is to audit this Kysely codebase.
IMPORTANT:
- Do not assume the code is safe because it is type-safe TypeScript.The runtime value is still a string.
- Do not assume the recent security patch fully closed the surface. Incomplete fixes are a vulnerability class.
- JSON-path is a separate sub-language. SQL-string escaping is NOT the same as JSON-path escaping. Treat them independently.
- Prioritize a REAL, reproducible finding over theoretical ones.
Your workflow:
1. Enumerate every place user-controllable input flows into a JSON path:
- JSONPathBuilder.key()
- JSONPathBuilder.at()
- eb.ref(col, '->\(') / '->>\)' chains
- visitJSONPathLeg in the query compiler
- per-dialect compiler overrides (MySQL / PostgreSQL / SQLite)
2. Trace the full data flow from .key(input) to the emitted SQL string.
- identify the exact sanitization function applied
- list every character class it neutralizes
- list every character class it does NOT neutralize
3. Identify dangerous patterns:
- JSON-path metacharacters passed through unescaped: . [ ] * ** ?
- string concatenation of a path leg into the SQL text
- separator emission (. or [ ]) around unsanitized input
- mismatch between the .at() type signature and runtime acceptance
4. Analyze the TypeScript types as an attacker
5. Build a practical PoC:
- a realistic endpoint shape (e.g. "read one field of my profile")
- the legitimate call vs the malicious input
- the exact compiled SQL before and after injection
- confirm end-to-end data disclosure on a real DB (better-sqlite3)
- check all three dialects
6. Be skeptical:
- eliminate false positives
- verify whether dialect-specific escaping exists
- confirm the behavior with .compile() output, not assumptions
Output format:
## Candidate N
- Vulnerability type
- Reachable surface
- Source
- Sink
- Vulnerable code (file:line)
- Why the existing sanitizer fails
- Type-system analysis (is type-safe code affected?)
- Payload / malicious input
- Reconstructed SQL after interpolation
- Impact
- Confidence level
At the end:
- Rank findings by likelihood of being exploitable.
- Clearly separate the confirmed finding from suspicious code.
- State explicitly whether the prior patch (0a602bf) is incomplete.
After that, the AI produced a result showing it had found the issue. The data flow it reconstructed was:
.key(userInput)
-> #createBuilderWithPathLeg('Member', userInput)
-> JSONPathLegNode.create('Member', userInput)
-> visitJSONPathLeg() // emits '.' then sanitizeStringLiteral()
-> appended directly into the SQL string
The sink it identified, in src/query-compiler/default-query-compiler.ts:
protected sanitizeStringLiteral(value: string): string {
return value.replace(LIT_WRAP_REGEX, "''") // LIT_WRAP_REGEX = /'/g
}
The model's conclusion: the prior patch (0a602bf) only escapes the SQL string-literal quote (' → ''). But JSON-path is a separate sub-language whose metacharacters . [ ] * ** ? pass through completely unescaped. The dot, in particular, is a path separator, so a single .key() call can be made to traverse into sibling and child fields.
The most important part of the analysis was the type-system reasoning. The AI showed that the prior advisory's assumption "only Kysely<any> code is affected" was wrong. When a JSON column is typed as Record<string, T> (a very common shape for metadata blobs), keyof O collapses to string, so TypeScript accepts any string as the key. The injection therefore triggers in fully type-safe code, with no escape hatch required. This made the finding strictly broader than the patched precedent.
I then set up an environment and continued to have the AI verify the bug end-to-end; it confirmed the bug was real on all three dialects.
Here is the vulnerable request (a typical "read one field of my own profile" endpoint backed by Kysely):
GET /api/v1/me/profile?field=nick
backed by:
db.selectFrom('me')
.select(eb => eb.ref('profile', '->>$').key(req.query.field).as('value'))
.where('id', '=', currentUserId)
.execute()
After that, I continued to use AI to build the traversal payload:
GET /api/v1/me/profile?field=internal.ssn
field value (the malicious input):
internal.ssn
Reconstructed SQL after interpolation (SQLite):
select "profile"->>'$.internal.ssn' as "value" from "me" where "id" = ?
The application only ever intended top-level keys (nick, tagline) to be readable; internal was private bookkeeping. Verification against a real better-sqlite3 database:
field = "nick" -> '$.nick' -> 'alice' (legitimate)
field = "internal.ssn" -> '$.internal.ssn' -> '111-11-1111' (injection)
field = "internal.token" -> '$.internal.token' -> 'tok_abcdef' (injection)
field = "internal.admin" -> '$.internal.admin' -> 1 (injection)
On MySQL and PostgreSQL ->\(/->>\) the impact is worse: the wildcard input * compiles to '$.*' and returns every value at the current depth in one round-trip, and ** recurses the whole document. In UPDATE statements the same path compiler is used, so an attacker who controls both the path and the value can write into nested fields they should not be able to set (for example flipping an admin flag).
I submitted the bug shortly afterwards; Kysely issued a fix in v0.28.17 and a CVE was assigned.
IV. Timeline
Apr 26 2026: Submit bug via the GitHub Security Advisory of the repository.
May 07 2026: Kysely team verify, publish the security advisory, release the fix in v0.28.17, and assign CVE-2026-44635.




![[CVE-2026-34612] AI-Assisted Discovery of SQL Injection Leading to RCE in Kestra v1.3.2](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F699fec8cc9015c37f6e5364f%2Fb524a566-1628-4c53-8b67-c2c37a822717.png&w=3840&q=75)
