An ORM app is still injectable through one raw query
“Most queries go through the ORM, but a search, report, or sort endpoint builds SQL from strings. Quotes in input cause syntax errors, and a scanner shows the sort or filter parameter changes query results.”
ERROR CODES YOU MAY SEE
A code is a clue. Use its meaning and surrounding evidence to narrow the cause.
42601PostgreSQL SQLSTATE · standardThe statement could not be parsed (syntax_error).
In code that only runs fixed or parameterized queries this should never happen at runtime. Spikes tied to one endpoint, especially with quotes or SQL keywords in the input, point to string-built SQL and active probing.
Official reference for 42601 (opens in new tab)Code definition reviewed
THE PRINCIPLE
Data goes in parameters; structure comes from your code. Anything that changes the SQL text must be a constant you chose.
FIRST MOVES
- Grep for raw escape hatches: $queryRawUnsafe, $executeRawUnsafe, Prisma.raw, sequelize.literal, knex.raw, whereRaw, and template strings passed to query().
- Convert value interpolation to bound parameters (tagged templates such as $queryRaw, or $1 placeholders with a values array).
- Map sort columns, directions, and table names through an allowlist; reject or default anything unknown.
- Do not hand-escape; escaping is not a substitute for binding.
- Run the app with a least-privilege database role and alert on spikes of SQL syntax errors from one endpoint.
TOOLS: THEN → NOW
String concatenation with manual escapingBound parameters via the driver, tagged templates, or a query builder
Passing user sort fields straight into ORDER BYAn allowlist that maps request values to fixed identifiers
PATTERN SNAPSHOT
import { Prisma } from "@prisma/client";
// Map, not a plain object: "__proto__" or "toString" cannot slip through.
const SORT_COLUMNS = new Map([["created", "created_at"], ["total", "total_cents"]]);
export function listOrders(userId: string, sort: string, dir: string) {
const column = SORT_COLUMNS.get(sort) ?? "created_at";
const direction = dir === "asc" ? "ASC" : "DESC";
return prisma.$queryRaw`
SELECT id, total_cents, created_at FROM orders
WHERE user_id = ${userId}
ORDER BY ${Prisma.raw(column)} ${Prisma.raw(direction)}
LIMIT 50`;
}CLOSE THE AI. EXPLAIN THIS.
Why can't you pass a column name as a bound parameter, and what makes Prisma.raw safe in the snippet but dangerous elsewhere?RELATED SYMPTOMS
SOURCES
Guide reviewed