Postgres lets you parameterise values, but not identifiers. A helper
that turns { status: "active" } into SET status = $1 must therefore
interpolate the column name directly into the SQL string.
Every one of the sixteen call sites passed hardcoded literal keys, so there was no exploitable path. But "safe by convention" is a fact about today's callers, not a property of the function — and the entire point of a shared helper is that people reuse it without re-deriving that history:
// Every current caller builds `updates` from hardcoded literal keys, never
// from request input directly — but the column names below go straight
// into the SQL string, not a placeholder, so this guards against a future
// caller doing something like `repo.update(id, req.body)` by construction
// rather than by convention.
const SAFE_COLUMN_NAME = /^[a-z_][a-z0-9_]*$/i;
export function buildUpdateQuery(updates) {
const fields = Object.keys(updates);
for (const field of fields) {
if (!SAFE_COLUMN_NAME.test(field)) {
throw new Error(`buildUpdateQuery: unsafe column name "${field}"`);
}
}
const values = Object.values(updates);
const setClause = fields
.map((field, index) => `${field} = $${index + 1}`)
.join(", ");
return { setClause, values, fields };
}
This is the least dramatic thing in the codebase and the one I would
defend hardest in review. It fixes a bug that does not exist yet, in a
place where the next person to call repo.update(id, req.body) would
have created it without noticing.
Guarding at the single point of construction costs one regex. Trusting sixteen call sites — and every future one — to remember costs whatever the first mistake costs.