SQL Injection
This document is normative: it names every way author-controlled text can reach the SQL this
library emits, the barrier that stops it at each one, and the rules a change has to follow to keep
that true. SECURITY.md states the policy — "a query, catalog or identifier that makes the
transpiler emit SQL doing something the query did not ask for … is the worst thing this project can
get wrong" — this is how that policy is kept.
Why this is the hard problem here
The usual answer to SQL injection is not available to koryki.
KQL text ─┐
├─→ parse ─→ IQL bean model ─→ rewrite rules ─→ validate ─→ render ─→ SQL string
IQL text ─┘ ▲ │
│ ▼
catalog (db.json, model.json) prepareStatement(sql)JdbcDatabase hands the rendered statement to Connection.prepareStatement(sql) and binds
nothing. There is no ? in anything the transpiler emits, because a KQL literal is a literal in
the SQL — a value written into the statement, not a parameter supplied beside it. That is a design
choice, not an oversight: the rendered SQL is meant to be read, diffed and kept as a golden, and a
statement full of ? says nothing about what it will do.
The consequence is that the escaping in the renderers is the entire boundary. There is no second line of defence behind it, so every path to the statement has to be named and every one has to have a barrier.
The one rule. Author-supplied text reaches the SQL only as the body of a string literal or the body of a quoted identifier — never as SQL itself. Everything below is that rule, applied.
Trust model
| Input | Supplied by | Trusted? |
|---|---|---|
| KQL query text | the end user, or an LLM writing on their behalf | no |
| IQL query text | whatever produced it — often the same source | no |
db.json / model.json |
the application, but possibly generated, uploaded or introspected | no |
| dialect function catalog | this repository | yes (it is code) |
| the database account's grant | the application | out of scope — see below |
The rights of the account handed to this library are explicitly out of scope: a transpiled query runs with exactly those, and no library can lower them. Grant the account only what the queries need. That is a control the application owns, and it is the one that decides what a successful injection would be worth.
Injection paths
Six places take text from outside and put it into a statement. Five are real paths with real barriers; section 5 lists what looks like a path and is not, and says why — because "this one is safe" is a claim that has to be checkable, not remembered. Section 6 is the newest path: the names inside a nested value.
1. Values — KQL and IQL string literals
The path everyone thinks of first, and the one with the most traffic.
FILTER c.company_name = 'O\'Brien' → WHERE c.company_name = 'O''Brien'Both front doors define the token identically, and this is deliberate — one escape convention, one renderer, one set of tests:
SQ_STRING : SINGLE_QUOTE ('\\\'' | .)*? SINGLE_QUOTE ; // KQLTokens.g4 and IQL.g4The mapper keeps the token with its delimiters (KQLQueryMapper.toExpression,
IQLQueryMapper.toExpression), and Literals.text converts it: strip the delimiters, unescape
KQL's \' to a quote, escape that quote again as SQL's '', put the delimiters back. The renderer
then hands the result to SqlDialect.textLiteral for whatever the engine needs on top (B2).
Barrier: the lexer (B1) and the literal escaping (B2), in that order. Both matter — see below.
2. Identifiers — the catalog
An entity's table, an attribute's column and the catalog's schemaPrefix are physical names,
written into the statement as identifiers. They cannot be literals and they cannot be parameters. A catalog is a JSON file, and an
application that generates one from an introspection run, accepts one per tenant, or lets a
scaffolding tool write one is handing this library names it did not choose.
"table": "customers\"; DROP TABLE orders; --"
→ FROM "customers""; DROP TABLE orders; --"The name appears in four positions, and each is reached by its own code path:
| Position | Renderer |
|---|---|
the table and its alias in FROM and the join tree — and the schemaPrefix in front of the table |
SqlSelectRenderer.toSql(Source, int) |
a column in the projection, WHERE, GROUP BY, ORDER BY |
SqlSelectRenderer.toSql(Field, int) |
an output header — … AS h |
SqlSelectRenderer.toSql(Out, int) |
the block name and its column list — WITH b (…) |
SqlQueryRenderer.toHeader |
The CTE column list is the reason this list is written out rather than summarised. It is documented
in SqlQueryRenderer as "the only identifier position that never reached normal": for a while it
emitted a physical column name raw while the same name inside the block body was quoted correctly.
One position that forgets is all an injection needs.
Barrier: identifier quoting (B3).
3. Function arguments a dialect reads back
This is the path that is easy to miss, and the one that actually leaked.
Almost every function is declared as a SqlTemplate — "strpos({0}, {1})" — and a template only
splices an already rendered operand into a fixed string. An operand that is a string literal is
a quoted, escaped literal by the time the template sees it. There is nothing to get wrong.
The exceptions are the functions whose dialect rendering inspects an argument: it strips the quotes off the rendered literal, reads the text inside, and builds SQL from it. That unquoting is the dangerous moment — what comes out is no longer inside a literal, and if it is written into the statement as-is, every character of the author's string is SQL.
The sites, as of this writing:
| Site | Reads | Disposition |
|---|---|---|
DuckdbBaseDialect.toIntervalUnit |
to_interval unit |
was the hole — now shape-checked |
OracleDialect.oracleToIntervalUnit |
to_interval unit |
safe: the fall-through re-uses the quoted unit |
TimeEncodings.intervalUnitSeconds |
to_interval unit |
safe: closed switch, default -> throw |
date_trunc on MariaDB, SQL Server, Oracle, SQLite |
unit | safe: closed switch, default -> throw |
FormatMask.translate (DuckDB, SQLite, MariaDB, Trino) |
to_char / parse_* mask |
safe: re-quoted on the way out |
MssqlDialect.compileMask |
to_char mask |
safe: each literal run is re-quoted |
MssqlDialect.unquote |
zone name | safe: the text goes to ZoneId.of, and what is emitted is the parsed zone |
Barrier: the shape of the value, checked by the dialect that reads it (B4). There is no generic one, which is exactly why this path needs a rule (see Hardening, R2).
4. The query description — a comment, not a value
A query may open with a comment. That comment becomes Query.description
(AbstractReader.getComment) and is written back out as a leading -- line by SqlQueryRenderer,
so the generated SQL says what it came from. IQLSerializer does the same with //.
No value is involved, so nothing about escaping values helps — and the payload lands in front of the query rather than inside it, which makes it the most powerful of the four: it is not limited to bending a filter.
/* note<CR>SELECT 42 AS injected -- */ FIND customers c FETCH c.customer_idBarrier: comment marking that covers every line break, not just \n (B6).
5. What is not a path, and why
Each of these carries author text and none of it reaches the statement as SQL. Verified against the transpiler, not assumed: where a row shows rendered SQL, that is what it actually emits.
| Input | Why it is not a path |
|---|---|
| numbers | parsed to BigInteger / BigDecimal and re-rendered by Literals.number; 0009 comes out as 9 |
DATE / TIME / TIMESTAMP literals |
grammar-constrained tokens, parsed to java.time, re-rendered (DATE '2020-01-01') |
| durations | DURATION : (DIGIT+ ('ms'|'s'|'min'|'h'|'d'|'w'|'mo'|'q'|'y'))+ → components → INTERVAL '2 day 4 hour' |
LIMIT |
Integer.parseInt, rendered as an int |
| window frame bounds | built from Limit.PRECEDING(i) and friends — keywords and an int |
FETCH … h headers |
h=ID, i.e. [a-z_][a-z0-9_]*, and quoted anyway by renderIdentifier |
FETCH … h "label" labels |
held on Out.label for the result heading and the IQL round-trip; never rendered into SQL |
placeholders #name |
PlaceholderValidator refuses a query that still has one — the pipeline stops before rendering |
| custom operators | custom=ID, so the alphabet is [a-z_][a-z0-9_]*; no payload fits in it |
| result output (CSV, XML) | XMLStreamWriter.writeCharacters escapes; CSV quotes — a different threat model, not this one |
6. Selector keys — names inside a nested value
A selector names something inside a value: t.attrs@'a key' a map key, t.doc@'a"b' a JSON member,
t.dims@w a struct field. The name is the author's text, and the dialect writes it into the
statement in whichever way its engine reaches into the value — and that is four different ways:
| Written as | Where | Example |
|---|---|---|
| a text literal | a map key, a struct field name handed to a function | element_at(t.attrs, 'k'), GET(t.dims, 'w') |
| the body of a JSON path inside a literal | a JSON member | (t.doc -> '$."a b"') — two quotings, the path's and the literal's |
| a quoted identifier | a struct field | (t.dims).w, t.dims.w, (t.dims)."odd name" |
A name that is not a plain identifier is quoted for the path (" escaped as the path syntax says)
and the result is then written as a literal through NestedSql.literal, which doubles the quote and
applies SqlDialect.textLiteral, so a backslash is doubled on MariaDB and Snowflake too. A struct
field is matched against the declared names (NestedSql.fieldName), and a name the type does not
have is refused when the query is checked; a field is written as an identifier through
NestedSql.identifier, quoted when it is not plain.
Fields of an unnest element are a case of their own: the names come from the catalog, not from the
query, and SQLite, MariaDB and SQL Server write each one into a JSON path literal (json_extract(i.value, '$."a b"'), PATH '$."a b"', JSON_VALUE(i.value, '$."a b"')). They go through
NestedSql.jsonMemberPath (the path's quoting, backslash and " escaped) and then NestedSql.literal,
as a member selector does. The first version of the MariaDB and SQL Server code wrote the name into the
literal unescaped; the catalog is untrusted input (see above), so that was an injection.
A lambda parameter is a bare word of the query, [a-z_][a-z0-9_]*, so no payload fits in it, but
the words that are reserved in SQL are legal parameter names. The renderers write a parameter as a
derived alias (_l0_x) or as LA0.X, never as the bare word.
Barrier: B2 (the literal) and B3 (the identifier), applied to the key by NestedSql — one place,
for all eight dialects, as R1 requires. HostileSelectorTest (tools) holds every dialect to it for a
dozen keys, and the corpus hostile_keys / hostile_json_keys runs them on the live engines.
Barriers
Six mechanisms, each with a defined job and a defined limit. A barrier that is credited with more than it does is worse than no barrier.
| # | Barrier | Where | Stops | Does not stop |
|---|---|---|---|---|
| B1 | Lexer literal rule | KQLTokens.g4, IQL.g4 |
a bare ' inside a literal |
anything after the literal is parsed |
| B2 | Literal escaping | SqlSelectRenderer, SqlDialect.textLiteral |
quote break-out in a value | text that never becomes a literal |
| B3 | Identifier quoting | SqlDialect.renderIdentifier / quote |
catalog and alias break-out | a position that forgets to call it |
| B4 | Validation | FunctionValidator, PlaceholderValidator |
wrong arity, wrong family, unbound placeholders | a value the family accepts |
| B5 | Template rendering | SqlTemplate |
re-expansion of filled operands | a dialect that bypasses the template |
| B6 | Comment marking | Text.lineComment |
a description escaping its comment | nothing else; it has one job |
B1 — the lexer refuses, it does not escape
SQ_STRING is non-greedy: it prefers the escape reading of \', and ends at the first quote that
reading leaves over. (Only prefers — where treating \' as an escape would leave the token
unterminated, the backslash is an ordinary character and the quote after it closes the literal. That
second reading is what the trailing-backslash case below turns on.) A payload written as
'x' OR 1=1 --' is therefore not a literal with a quote in it — it is a literal, then junk, and the
parser rejects the query. Refusing is better than escaping: an author who wrote a stray quote
gets a syntax error pointing at it, and the renderer never has to be clever.
This also means an SQL-style '' is not a KQL escape. The two conventions do not meet until the
renderer, which is where the translation is made once.
B2 — one escape, applied once, adjusted per dialect
Literals.text produces the doubled quote '', the one escape all eight engines share — working on
the literal's body, never on the delimited token, for the reason the trailing-backslash case below
records. Doubling alone is not enough everywhere, though, and that is why SqlDialect.textLiteral
exists. Three dialects override it:
- MariaDB / MySQL and Snowflake read
\as an escape *inside a string literal*, soMariadbDialect.textLiteralandSnowflakeDialect.textLiteraldouble every backslash. Without that, the KQL'x\\' OR 1=1 -- '— a perfectly ordinary literal whose value isx\' OR 1=1 --— renders as'x\'' OR 1=1 -- ', and those two engines read the\'as an escaped quote, take the next'as the closing one, and executeOR 1=1. The other six read the backslash as an ordinary character, so the same text is one literal there. This is the clearest case in the project of a defence that has to be per engine: one escape convention is not enough, and a dialect that forgets this has an injection, not a formatting bug. - SQL Server prefixes a non-ASCII literal with
N, which is a collation matter, not a security one.
B3 — quoting is a dialect property, doubling is not
renderIdentifier is the single place that decides. It quotes when Identifier.needsQuoting (the
name is outside [A-Za-z_][A-Za-z0-9_]*, or is not all lower case) or when the dialect's
isReserved says so, and the quoting itself is SqlDialect.quote:
| Dialect | Delimiter | Escape |
|---|---|---|
| default (7 of 8) | double quote | doubled double quote |
| MariaDB | backtick | doubled backtick |
| SQL Server | [ … ] |
doubled ] |
Doubling the delimiter is the invariant — never a backslash, which is not an identifier escape on
any engine here. A dialect that overrides quote has to keep it.
Note what B3 does not do: it cannot help a caller that builds alias + "." + column without going
through it. Qualified names — alias.column, schema.table — are assembled from two
renderIdentifier calls for exactly that reason.
B4 — the validator checks types, not values
FunctionValidator enforces arity and argument family — to_interval(value, unit) requires
NUMERIC and TEXT. It does not, and should not, know which text is a legal interval unit: that is
the dialect's knowledge. So B4 narrows the path but never closes it, and a dialect that reads a value
back out cannot lean on it.
B5 — a template never rescans what it filled in
SqlTemplate parses its template once into Literal / Arg / Rest segments and appends. A filled
operand is never re-examined, so a KQL literal '{1}' stays an operand instead of being replaced by
the value of another argument. The naive implementation — a chain of String.replace("{0}", …) —
has precisely that bug.
B6 — every line break, not just \n
Text.lineComment(marker, description) splits on the regex \R, which is every line boundary Java
knows: CR, LF, CRLF, NEL and the two Unicode separators. The SQL standard ends a simple comment at a
newline, without fixing which characters spell one; DuckDB, measured, ends one at a bare CR.
Splitting on \n alone therefore leaves a hole on any engine that agrees with DuckDB, and a comment
whose safety depends on an engine not treating CR as a newline is not a defence worth keeping.
Splitting on \R closes the question for all of them, and costs at most a line break a description
did not ask for.
Where barriers have failed
Two defects, both found in the September 2026 review, both fixed, both with a test that goes red when the fix is reverted. They are recorded because the shape of each is more useful than the fix.
to_interval(n, unit) — an unquoted read-back (DuckDB family)
DuckdbBaseDialect.toIntervalUnit stripped the quotes off the rendered unit literal and, for a unit
its switch did not recognise, wrote the body into the SQL unquoted:
default -> "INTERVAL (" + value + ") " + bare; // bare = the author's string, unquotedSo this KQL
FIND orders o FETCH to_interval(1, 'DAY, (SELECT company_name FROM customers LIMIT 1) AS leaked')rendered — and DuckDB executed —
SELECT
INTERVAL (1) DAY, (SELECT COMPANY_NAME FROM CUSTOMERS LIMIT 1) AS LEAKED
FROM
orders oan output column the query never asked for, read from a table it never named.
Shape: a read-back with an open default. The fix requires the unit to match [A-Za-z]+ — a
word carries no quote, paren, comma, semicolon or comment marker, so whatever the engine makes of an
unknown unit it stays one token in the position the renderer put it. Deliberately not an allow-list
of known units: the pass-through for units DuckDB knows and the switch does not (DECADE,
CENTURY) is a feature, and narrowing it would be a behaviour change wearing a security fix's
clothes.
A carriage return in the description — a comment that ended early
SqlQueryRenderer prefixed -- after every \n. A KQL block comment accepts any character its
.*? matches, a bare CR included. Measured on DuckDB, --note\rSELECT 42 AS injected answers one
column, named injected — the comment ended at the CR and the rest was a statement. (Only DuckDB
was measured; see B6 for why the other seven do not need to be.)
Shape: a defence written against one spelling of a concept that has several. The fix is
Text.lineComment, shared by the SQL renderer and the IQL serializer so the two cannot drift.
A trailing backslash — a literal the renderer left open
The lexer and the renderer disagreed about one character. SQ_STRING prefers the escape reading of
\', but only where that reading yields a token at all; where it does not, the backslash is an
ordinary character and the quote after it closes the literal. So the KQL 'a\' is a literal whose
value is a\. The renderer, which ran replace("\\'", "''") over the token including its
delimiters, read the same two characters as an escape and emitted
WHERE c.company_name = 'a''— a literal with no terminator, which swallows whatever was rendered after it.
Shape: two readings of one escape, in two places, neither aware of the other. The fix is
Literals.text: strip the delimiters first, unescape the body, escape it again for SQL, put the
delimiters back — so neither step can mistake a delimiter for content.
No exploit was demonstrated for it. The shapes that would place attacker text after the open literal do not survive KQL lexing, because the same backtracking that creates the bug consumes the second literal differently. It is recorded as a defect rather than a vulnerability: a valid query rendering SQL that does not parse is wrong on its own, and an unterminated literal is the shape an escaped-wrongly value takes before it is anything worse.
Found by generated input, not by a case anyone wrote — see R4.
Still open, and not an injection
MssqlDialect.compileMask re-escapes a literal run that FormatMask.body already returned escaped,
so a quote inside a to_char mask becomes '''' and renders as two quotes instead of one. A
correctness defect, not a break-out: the literal stays balanced.
Hardening strategy
R1 — one place decides, and the callers cannot opt out
Every identifier goes through renderIdentifier; every literal through textLiteral; every
description through lineComment. The CTE column list is on record as what happens otherwise: the
same fix applied twice, and the third call site never found. When a second caller appears, it should
inherit the decision rather than have to remember it.
Applied: a helper that both quotes and renders, never one that returns something a caller is trusted to quote afterwards.
R2 — never un-escape into the statement
This is the rule the to_interval defect was missing, and it is the one to check first in any
review:
If a dialect takes the quotes off a rendered literal, the text it gets out may only be compared, never emitted. If something derived from it has to be emitted, it is either a constant the dialect chose, or it is re-quoted.
Concretely, a read-back switch has exactly three legal endings:
default -> throw new …; // reject: date_trunc, intervalUnitSeconds
default -> "F(" + value + ", " + unit + ")"; // re-quote: Oracle's to_interval
default -> { if (!SHAPE.matcher(bare).matches()) throw …; yield …; } // shape-check: DuckDB's to_intervalAn ending that concatenates bare without one of those three is a finding.
R3 — grep for the shape, not for the bug
The read-back sites are findable mechanically, which is what makes this auditable rather than
folkloric. Run this over core and the dialect modules after any function work:
grep -rn --include='*.java' \
"startsWith(\"'\")\|substring(1, .*length() - 1)\|replace(\"'\", \"\")" \
*/src/main/javaIt finds "something is taking a delimiter off", which is wider than path 3 — Identifier.bare
strips identifier quotes, the two query mappers strip a label's, SqlDialect.unwrapOuterParens
strips parentheses, and none of those is a literal read-back. Sort the hits into the two kinds and
check the literal ones against R2. The table in path 3 above is the current census; keep it
current.
R4 — tests assert on the skeleton, not on goldens
core/src/test/java/ai/koryki/security/SqlSkeleton empties every string literal and quoted
identifier out of the rendered SQL and leaves the statement's structure. Assertions are made against
that:
No part of an author-supplied string may appear outside the literal it was written in.
This matters more than the extra machinery suggests. A contains("''") assertion says the escaping
fired somewhere; it does not say the payload stayed put, and it goes on passing when a second,
unescaped copy leaks out elsewhere. The skeleton catches both that and the opposite failure — an
unterminated literal, which is the classic injection signature — and it does not move when a
renderer change keeps the meaning and shifts the text, so it reads as a policy rather than a golden.
Two of those cases are generated rather than written. HostileLiteralTest fuzzes the literal and
the description paths — 400 rounds each from a fixed seed, -Dfuzz.seed to vary it — skipping input
that does not parse and asserting that enough did that the generator is testing something. The
property is the same one the written cases check, which is the point: generated input does not share
the author's idea of what an interesting character is. It found the trailing-backslash defect above
on its first run, at a shape no enumerated case had.
Current coverage:
| Test | Module | Path |
|---|---|---|
security/LiteralInjectionTest |
core | 1 — =, IN, LIKE, projection, the backslash-before-escape shape, and B1's refusals |
security/CatalogIdentifierInjectionTest |
core | 2 — hostile names in all four positions; quote and needsQuoting as rules |
security/FunctionArgumentInjectionTest |
core | 3 — to_interval, format masks, SqlTemplate, the custom-operator alphabet |
security/CommentInjectionTest |
core | 4 — CR, LF, CRLF, and lineComment itself |
HostileLiteralTest |
tools | 1 and 3, for all eight dialects, each read with its own literal syntax; plus the generated-input pass over paths 1 and 4 |
HostileIdentifierTest |
tools | 2, for all eight dialects — spaces, keywords, embedded quotes, the CTE list |
HostileSelectorTest |
tools | 6, for all eight dialects — hostile map keys, struct fields and JSON members, in chains and as function arguments; reserved words as lambda parameters, hostile fields of an unnest element on SQLite, MariaDB and SQL Server |
SqlInjectionEngineTest |
duckdb | all four, against a running engine |
corpus hostile_keys, hostile_json_keys, lambda_reserved_words |
every dialect | 6, against live engines |
HostileNameSnowflakeTest, MixedCaseNameOracleTest |
snowflake, oracle | 2, against live engines |
The two tools tests are the cross-dialect layer, and they are there because core cannot see the
dialect modules — it is their dependency, not the other way round. tools already depends on
seven of the eight for its documentation generators, which makes it the one place a per-dialect
invariant can be stated once instead of eight times.
HostileLiteralTest deserves a note on how it asserts. Doubling the quote is the escape all eight
engines accept, but they do not agree on what else is an escape inside a literal, so one shared
expectation would be meaningless: the same rendered bytes are one literal on six engines and two on
MariaDB and Snowflake. The test therefore carries a per-dialect reading of the literal syntax and
empties the statement using that. Verified to bite: with MariadbDialect.textLiteral and
SnowflakeDialect.textLiteral reduced to returning their argument, it fails with
snowflake: unterminated string literal from offset 75 — this SQL does not parse:
c.company_name = 'x\'' OR 1=1 -- 'which is the injection itself, not a proxy for it.
The engine test is what closes the argument. core asserts what the transpiler writes, which
takes on faith that a database reads it the way the test does. SqlInjectionEngineTest runs the
payloads on DuckDB, on its own temp copy of the Northwind file, and rests each case on a distinction
an injection either crosses or does not: a tautology that stays data matches 0 rows where a
landed one matches all 91; a DROP payload leaves the table standing.
R5 — the checklist for a new dialect or function
A new dialect, or a new function on an existing one, is the moment a barrier gets forgotten. Before merging:
- Does any rendering read a literal back? If so, does it end in one of R2's three forms?
- Does
quotedouble the delimiter, and is the delimiter the engine's own? - Does
textLiteralaccount for the engine's escape characters — a backslash in particular? - Does
isReservedadd what this engine reserves and nothing it does not? (A word quoted unnecessarily resolves differently on Oracle and Snowflake — over-quoting is not free.) - Is every identifier emitted through
renderIdentifier, including alias and column-list positions? - Does the dialect's function catalog declare argument families, so B4 narrows what reaches the renderer?
Open work
| Work | Why | |
|---|---|---|
| 1 | Extend the suite across the remaining seven dialects — done, by tools/HostileLiteralTest alongside the HostileIdentifierTest that already covered identifiers |
— |
| 2 | Validate the catalog at load time | CatalogLoader accepts any name. Quoting makes a hostile name harmless, but a name that could never have been created in the database is more likely a sign of tampering than a real column, and LinkResolver is the place to say so. |
| 3 | Fuzz the front doors — done for the literal and description paths (HostileLiteralTest), and it found the trailing-backslash defect above on its first run |
IQL's front door is still only covered through the shapes KQL can express. |
| 4 | Consider emitting bind parameters | The largest structural change available, and not obviously the right one: it would remove path 1 outright, and cost the readable, diffable, golden-able SQL the project is built around. If it is ever taken, it is taken as a rendering mode, so the goldens keep their literals. |
| 5 | Keep the read-back census current (R3) | It is a table in a document, which is the weakest kind of guarantee. A test that fails when a new read-back site appears would be better. |
Reporting a suspected injection
Privately, through GitHub's private vulnerability reporting — see SECURITY.md. The most useful
report names the KQL or catalog that reproduces it and the SQL it rendered.