← back to Directory Core

src/_ident.ts

39 lines

// directory-core / _ident
//
// Defends every SQL identifier interpolation site (table names, column names,
// schema-qualified column refs) against injection. Audit P1-c 2026-05-04: all
// the *.ts modules that template identifiers into SQL strings — auth.ts (T_USERS,
// T_SESSIONS, SESSION_PK, SESSION_FK, USER_COLUMNS), match.ts (opts.table,
// opts.whereExtra), geo.ts (makePgZipCache table) — were trusting their callers.
// Today every caller is hard-coded, but one `createAuth({ userTable: req.query.t })`
// mistake = full DB takeover. This module makes the trust boundary explicit.

const SAFE_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;

/**
 * Throws if `s` is not a safe SQL identifier. Accepts plain idents (`app_users`)
 * and one level of schema-qualification (`public.app_users`). Rejects spaces,
 * quotes, semicolons, comment markers, and anything else that could break out
 * of an identifier position in PostgreSQL grammar.
 *
 * @param s    candidate identifier
 * @param what optional label for the error (e.g. 'userTable', 'sessionPkColumn')
 */
export function assertSafeIdent(s: unknown, what = 'identifier'): asserts s is string {
  if (typeof s !== 'string' || !SAFE_IDENT.test(s)) {
    throw new Error(
      `directory-core: refusing to interpolate unsafe ${what} into SQL: ${JSON.stringify(s)}`
    );
  }
}

/**
 * Asserts every element of an array is a safe identifier. Used for column lists.
 */
export function assertSafeIdents(arr: unknown, what = 'identifier list'): asserts arr is string[] {
  if (!Array.isArray(arr)) {
    throw new Error(`directory-core: ${what} must be an array of strings`);
  }
  for (const s of arr) assertSafeIdent(s, `${what} item`);
}