iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Allow-list for Identifiers

When you can’t parameterise (dynamic ORDER BY, table names, column names), use an allowlist. Map untrusted input to a finite set of known-safe values BEFORE building the SQL string.

Dynamic ORDER BY + dynamic table names safely

EXAMPLE
// 1) The dangerous default — string concatenation
// BAD (Node, pg)
const sql = `SELECT * FROM orders ORDER BY ${req.query.sort} ${req.query.dir}`;
await db.query(sql);   // sort=DROP_TABLE_USERS-- destroys you

// 2) Allowlist — map untrusted input to a known-safe value
const SORT_COLUMNS = {
    id:        'id',
    created:   'created_at',
    total:     'total',
    customer:  'customer_id',
};
const DIRECTIONS = { asc: 'ASC', desc: 'DESC' };

const sort = SORT_COLUMNS[req.query.sort] ?? 'created_at';
const dir  = DIRECTIONS[req.query.dir]    ?? 'DESC';

const safeSql = `SELECT * FROM orders WHERE user_id = $1 ORDER BY ${sort} ${dir} LIMIT 50`;
await db.query(safeSql, [req.user.id]);

// 3) Dynamic table names — allowlist them too
const TABLES = new Set(['orders', 'invoices', 'shipments']);
if (!TABLES.has(req.params.entity)) return res.status(404).end();
const sql2 = `SELECT count(*) FROM ${req.params.entity}`;

// 4) Search filters — parameterise + allowlist the field
const FILTERS = {
    email:  ['email',  'ILIKE'],
    status: ['status', '='],
    region: ['region', '='],
};

const where = [];
const params = [];
for (const [key, value] of Object.entries(req.query.filter ?? {})) {
    const spec = FILTERS[key];
    if (!spec) continue;
    const [col, op] = spec;
    params.push(op === 'ILIKE' ? `%${value}%` : value);
    where.push(`${col} ${op} $${params.length}`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
await db.query(`SELECT * FROM users ${whereSql} LIMIT 100`, params);

// 5) ORMs that handle this — Drizzle, Prisma, Knex
// Drizzle:
await db.select().from(users)
    .where(sql.raw`${columns.email} ILIKE ${'%${term}%'}`)   // identifier is safe (typed), value is parameter
    .orderBy(columns[sortKey]);

// Prisma — orderBy is a typed object, can't be a free-form string
await prisma.user.findMany({
    orderBy: { [SORT_COLUMNS[sortKey] ?? 'createdAt']: dir.toLowerCase() },
});

// 6) DON'T do these
// pg.escape(input) ... `WHERE x = ${input}`     // string escaping is fragile
// `WHERE x = '${input.replace("'","''")}'`     // misses many edge cases / drivers
// Stored procedure with dynamic SQL inside (sp_executesql in T-SQL) without parameters

// 7) Logging — never log raw queries with user input
// log.info(`Running: ${sql}`)  → goes into SIEM, becomes a confused-deputy

Why it matters

Allowlists are the workaround for the things parameterisation can’t do (ORDER BY, table/column names). Treat them as a strict whitelist; default the unknown to a sensible value, never to the input.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// You can't parameterise identifiers (column / table names / direction).
// Use an allow-list:
const sortable = { name: 'name', age: 'age' };
const col = sortable[req.query.sort] ?? 'name';
const dir = req.query.dir === 'desc' ? 'DESC' : 'ASC';
await db.query(`SELECT * FROM users ORDER BY ${col} ${dir}`);
Try it Yourself »

Exercise

Map raw direction to a safe value.

const dir = req.query.dir === 'desc' ? 'DESC' : ' ';

Test yourself

Q1. You can't parameterise…
Q2. For sort direction, use…
Q3. Allow-listing is preferred to block-listing because…

Discussion

Loading…