/ 3 min read
The query that trusted its input: finding SQL injection in a no code platform
How a code review turned up SQL injection in the database nodes of an API platform, and what fixing it properly across six database engines involved: bind parameters, identifiers and a safer way to inspect queries.
- Security
- Databases
- Code review
The API platform I worked on lets teams build endpoints by connecting nodes, and one of the most used nodes runs a database query. You write the SQL, mark the places where values from the request go, and the node fills them in at runtime. It worked, it was popular, and while reviewing its code I noticed how the values were filled in: as text, pasted into the query string.
That is SQL injection, one of the oldest vulnerabilities there is, sitting inside a product whose purpose is to expose data safely. This is how it happened, why it is easy to miss, and what the fix looked like.
How it slips in
Nobody writes injectable code on purpose. In a no code tool it usually arrives through a reasonable sounding feature: templates. The node let users write placeholders such as {{request.query.status}}, and the simplest way to implement placeholders is a string replace before the query is sent to the database.
const sql = template.replace(/{{(.*?)}}/g, (_, path) => String(get(context, path)))
await connection.query(sql)
// status = "active' OR '1'='1"
// SELECT * FROM accounts WHERE status = 'active' OR '1'='1'Once the value is part of the text, the database cannot tell data from instructions. A request parameter crafted with a quote and a condition changes what the query means. In a platform that publishes these flows as public APIs, that parameter can come from anyone on the internet.
The fix: bind parameters
Every serious database driver supports bind parameters: the query text and the values travel separately, and the database treats the values purely as data. The fix was to keep the friendly placeholder syntax for users, but compile it into a parameterised query instead of a string.
function compile(template: string, context: Context, dialect: Dialect) {
const values: unknown[] = []
const text = template.replace(/{{(.*?)}}/g, (_, path) => {
values.push(get(context, path.trim()))
return dialect.placeholder(values.length) // $1, ?, :p1, @p1 ...
})
return { text, values }
}
const { text, values } = compile(node.query, context, dialects[connection.engine])
await connection.query(text, values)Six engines, six dialects
The platform connects to six database engines, and they do not agree on how parameters look. PostgreSQL numbers them as $1, MySQL and SQLite use ?, Oracle uses named :p1, SQL Server uses @p1. Some reuse a named parameter when it appears twice and some do not. Types differ too: dates, decimals and booleans need care so a value bound in one engine means the same in another.
So the specification I wrote for the fix was per engine: the placeholder syntax, how repeated placeholders are handled, how each value type is bound, and a test suite that runs the same set of hostile inputs against every engine and checks that each one comes back as data, never as SQL.
Values are easy, identifiers are not
Bind parameters work for values, but not for table names, column names or sort directions. A user who wants to sort by a column chosen in the request cannot bind the column name. For those, the rule is an allowlist: the node configuration declares the columns that may be used, the runtime checks the request against that list, and the identifier is quoted with the engine's own quoting rules. Anything not on the list is rejected before a query is built.
Inspecting a query without running it
The editor also needed to know which columns a query returns, so later nodes could map them. The old way to find out was to run the query, which is a bad idea for a statement someone is still writing. The fix included a read only inspection mode that asks the database to describe the result columns without executing the statement, so the editor gets the shape of the data without touching the data.
What I took from it
- Read code you did not write. The bug was found in a code review. A frontend engineer reading backend code is not overstepping; it is how issues between layers get caught.
- Look hardest at convenience features. Templates, dynamic filters and "advanced mode" text fields are where unsafe shortcuts hide.
- Fix the class, not the instance. One patched query would have left the next node with the same problem. A shared compiler with per engine rules fixes every node at once.
- Prove it with hostile tests. A suite of injection payloads that runs against every engine on every change keeps the fix fixed.
Next article ↗
APIs a frontend can live with: designing the contract first