Some platforms let you write HTTP endpoints as small scripts. A request comes
in, a script runs, and the script builds SQL. Most of these platforms offer
parameter binding, but nothing forces anyone to use it—and developers given
a string concatenation operator will, inevitably, concatenate strings. After
a few years, there are thousands of scripts that glue request values into query
text with + and template strings.
Reading all of them is not realistic. Grepping for concatenation finds every script that builds SQL dynamically, most of them harmless, because the concatenated value is a constant, a validated enum or a number that the script parsed itself. What you actually want to know is narrower: where does attacker-controlled text reach a query, and when it does, does it change the query’s structure?
This post describes a runtime answer to that question: request-scoped taint tracking inside the script engine, plus a lexical check right before each query runs. It only records findings and never blocks. It is cheap enough to leave on in production.
The criterion: stay inside one token
Su and Wassermann gave a clean definition of injection in The Essence of Command Injection Attacks in Web Applications (POPL 2006): an injection happens when input changes the syntactic structure of the query, rather than only filling in a leaf of it.
A full parse of the query isn’t needed to enforce a useful version of that. It is enough to look at tokens. Every tainted span must stay inside one token, and that token has to be one of two things:
- The inside of a closed string literal.
'...'with the taint strictly between the quotes. - A single standalone value, such as a number or a word (an identifier or
keyword). A signed number like
-1counts as one value even though the lexer produces two tokens.
Anything else is a finding:
Query text (tainted part in [...]) | Verdict |
|---|---|
WHERE name = '[alice]' | fine: inside a closed string |
WHERE id = [42] | fine: one number |
WHERE id = [-1] | fine: one signed number |
ORDER BY [created_at] | fine: one word |
WHERE name = '[o'brien]' | quote: the taint closes the string |
WHERE id = [1 OR 1=1] | multi-token |
WHERE id = [1; DROP TABLE t] | semicolon |
WHERE id = [1 --] | comment |
ORDER BY a [,] | syntax: a lone operator or punctuation token |
The o'brien row is worth a second look. It is not an attack; people named
O’Brien have simply been breaking naive SQL for decades. But that is the
point: a structural check finds the injectable code path on benign input, as
long as the input happens to contain the right character.
Step 1: mark sources by identity
When a request arrives, every string in its parameters, path variables,
headers and decoded body is registered as tainted. A container is walked
recursively, and each string leaf is registered on its own, under a source
name such as body.filter.name or query.id.
The registry is an IdentityHashMap<String, Range[]> that lives for exactly
one request, on the request’s thread. Keying by object identity instead of
by content matters: if a script contains the literal "admin" and a request
also sends "admin", only the request’s string object is tainted. Matching by
content would taint every constant that happens to equal user input.
There is one caveat with identity on the JVM. If the JSON parser interns string values, a request value and a constant could end up being the same object. Parsers commonly intern field names, not values, but check yours.
Step 2: propagate through the script
The engine is an interpreter, so propagation hooks go where strings are produced:
- Concatenation. For
a + b, the result keepsa’s ranges at their offsets andb’s ranges shifted bylen(result) - len(b). Computing the shift from the end is deliberate: the left side may be a number or an object whose string form you’d otherwise have to recompute. - Template strings. The template builder records the offset where each interpolated value lands and attaches the shifted ranges to the result.
- Method calls. The engine doesn’t know what an arbitrary method does, so
it uses two generic rules:
- If the result is a substring of a tainted operand, or the same string in a
different case, project the ranges by position. That covers
trim,substringandtoLowerCase. - Otherwise, search the result for the original text of each tainted
fragment, and taint wherever a fragment appears. That covers
format,join, and the untouched parts ofreplace.
- If the result is a substring of a tainted operand, or the same string in a
different case, project the ranges by position. That covers
Fragment search is a heuristic, and it errs toward over-tainting. A one-byte
tainted value such as "1" will match every 1 in the result. Because the
system only records findings, a false positive costs a line in a log. A false
negative would cost much more, so erring on the side of false positives is the
right trade-off.
Some places are blind to all of this, and it’s worth listing them:
- code that runs on another thread (async blocks, thread pools) doesn’t see the request’s scope;
- concatenation done inside Java code, such as a
StringBuilderor a fluent SQL builder, bypasses the interpreter’s hooks; - a method that rewrites content, such as escaping, can lose the parts whose original text no longer appears in the result.
Step 3: check at the sink
Right before a query executes, the engine looks up the taint ranges of the final SQL string and runs the check:
- Merge overlapping and adjacent spans. Two values that are each fine on
their own can escape together.
1is one number and-1is one signed number, but1+-1is1-1: three tokens. - Tokenize the query with a lexer, not a parser.
- For each merged span, collect the non-whitespace tokens it touches, and apply the rules from the table above.
A lexer is enough because the criterion only needs token boundaries. It also has to work on partial or broken SQL, which is exactly what injected queries often are, and a lexer copes with that much better than a parser.
The lexer does have to know the database’s real lexical rules, because an attacker will use whichever rule you forgot. For PostgreSQL that means at least the following:
'...'with''as the escaped quote;E'...', where backslash escapes are active, so\'doesn’t end the string;B'...',X'...',N'...'andU&'...'prefixes;- dollar quoting:
$tag$ ... $tag$; "quoted identifiers";--comments and/* */comments, which nest in PostgreSQL;$1and?parameter markers.
An unclosed string or comment runs to the end of the input and is flagged as unclosed: taint inside an unclosed string is a quote finding, and taint inside an unclosed comment is a comment finding.
Each finding records the source names of the spans, the reason, the tainted fragment and some surrounding context. Roughly:
reason=QUOTE source=body.filter.name
...WHERE customer_name = '[o'brien]' AND status = 1...
Making it safe to leave on
The hooks run on every string concatenation in every request, so they have to be harmless:
- Never throw. Every hook catches its own errors. On failure it drops the taint scope for the current request, logs once, and lets the request carry on untracked.
- Bounded memory. The registry caps how many strings it holds and how many
characters they add up to. That stops loops like
sql = sql + piecefrom registering every intermediate string. When the per-string range list grows too long, adjacent ranges merge pairwise. Merging loses precision but never drops a tainted byte. - Near-zero cost when idle. If the scope is empty, each hook pays a single
ThreadLocalread and returns. - Record, don’t block. Blocking would turn every false positive into an outage, and nobody would keep it enabled.
What it doesn’t catch
Token confinement only covers syntactic injection. It misses several things:
- Second-order injection. A value stored yesterday and read back from the database today is no longer tainted.
- Semantic abuse inside one token.
ORDER BY [password_hash]is a single word and passes the check, but the caller has still chosen a column it shouldn’t. Identifiers in sort and column positions need an allowlist, not a taint check. - Pattern injection.
%and_inside aLIKEstring stay inside the literal, but they still change what the query matches. - Anything evaluated after the check. If a downstream layer re-interprets the SQL text (such as expanding template placeholders), the check must run after that expansion, or the taint metadata must be preserved across it.
Using the findings
Benign production traffic rarely contains the characters that break a token. So the findings log works in three ways:
- A tripwire. A real injection attempt shows up immediately, with the endpoint and the parameter name.
- A coverage tool. In staging, send each endpoint the same request with a
'appended to every string field. Any endpoint that produces a finding is an injection point with a confirmed source and sink. You get that without reading the script. - A worklist. Sort findings by endpoint, fix them by switching to bound parameters, and watch them disappear from the log.
The mechanism is small: a few hundred lines for the taint registry and its propagation, and a few hundred more for the lexer and the check. In return, “thousands of scripts we can’t audit” becomes “a list of the ones that actually let input escape”.