Safe Sql

SQL identifier validation + dialect-aware quoting + allowlist gating. Defends against IDENTIFIER injection — the residual attack surface left over when a TABLE name or COLUMN name flows from operator-supplied config into a SQL string. Values bind through parameterized queries everywhere in the framework, but parameters can't carry identifiers; that interpolation is what this module guards.

Default identifier shape: ^[A-Za-z_][A-Za-z0-9_]*$, length 1–63 (Postgres NAMEDATALEN — the strictest of the supported dialects). Reserved words (SELECT / DROP / PRAGMA / ATTACH / …) and the SQLite-internal sqlite_ prefix are refused unless the caller explicitly opts in. Quoting follows dialect convention: SQLite + Postgres double-quote, MySQL backtick. Multi-segment names (schema.table) validate + quote each segment independently so the dotted form "schema"."table" resolves correctly instead of collapsing into one literal identifier with a dot in it.

Recommended pattern is the closed allowlist:

var ALLOWED = new Set(["audit_log", "consent_log"]); b.safeSql.assertOneOf(name, ALLOWED); var sql = "INSERT INTO " + b.safeSql.quoteIdentifier(name) + " ...";

The allowlist is the strongest guarantee. Operators with genuinely dynamic identifier needs use validateIdentifier alone, accepting that any string passing the regex is allowed.

Validation policy: every primitive throws SafeSqlError on bad input — these run at SQL-composition time, well before the query reaches the database. The throw IS the security signal.

b.safeSql.SafeSqlError #

stable0.1.0

Error class thrown by every b.safeSql primitive on bad input. Extends FrameworkError. Carries a stable .codesql/bad-type / sql/empty / sql/too-long / sql/null-byte / sql/bad-shape / sql/reserved-word / sql/internal-prefix / sql/not-allowed / sql/bad-allowlist. Operators catch these at SQL-composition boundaries; the throw fires before the query reaches the database driver.

var b = require("blamejs");
try {
  b.safeSql.validateIdentifier("drop");
} catch (e) {
  e instanceof b.safeSql.SafeSqlError;   // → true
  e.code;                                // → "sql/reserved-word"
}

b.safeSql.validateIdentifier(name, opts?) #

stable0.1.0
{
  pattern:              RegExp,  // override the default shape regex
  allowReserved:        boolean, // default false; permit reserved words like "select"/"drop"
  allowSqliteInternal:  boolean, // default false; permit "sqlite_..." identifiers
}

Throw-on-bad-shape validator for SQL table / column / index names. Enforces the default identifier regex ([A-Za-z_][A-Za-z0-9_]*), a 63-character cap (Postgres NAMEDATALEN — strictest supported dialect), no embedded null byte, no SQL reserved word, no SQLite-internal sqlite_ prefix. Returns name on success so the call composes inside a SQL fragment without an extra temporary.

var b = require("blamejs");
b.safeSql.validateIdentifier("audit_log");
// → "audit_log"

try { b.safeSql.validateIdentifier("drop"); }
catch (e) { e.code; }
// → "sql/reserved-word"

try { b.safeSql.validateIdentifier("evil; DROP"); }
catch (e) { e.code; }
// → "sql/bad-shape"

// Operator opts in to a custom shape (still ASCII-only, still capped).
b.safeSql.validateIdentifier("col-1", { pattern: /^[A-Za-z][A-Za-z0-9_-]*$/ });
// → "col-1"

b.safeSql.quoteIdentifier(name, dialect?, opts?) #

stable0.1.0
{
  allowReserved:  boolean,   // default: false — permit SQL-keyword names (safe once quoted)
}

Validate name then wrap it in dialect-appropriate quotes — double-quote for SQLite + Postgres (per SQL standard), backtick for MySQL. Default dialect is "sqlite". Throws SafeSqlError if the identifier fails validateIdentifier.

opts is forwarded to validateIdentifier — pass { allowReserved: true } to quote a name that collides with a SQL keyword (a column literally named from / select). Quoting is exactly what makes a reserved word safe in identifier position, so the query builder (b.sql) routes every identifier through here with allowReserved on; the default still rejects reserved words so a bare caller catches the likely typo.

var b = require("blamejs");
b.safeSql.quoteIdentifier("users");
// → '"users"'

b.safeSql.quoteIdentifier("Order", "postgres");
// → '"Order"'

b.safeSql.quoteIdentifier("from", "postgres", { allowReserved: true });
// → '"from"'

b.safeSql.quoteIdentifier("users", "mysql");
// → "`users`"

b.safeSql.quoteQualified(parts, dialect?) #

stable0.1.0

Quote a multi-part qualified name like schema.table or database.schema.table. Each segment is validated and quoted independently so the resulting SQL is "schema"."table" (three lookups against the catalog) instead of "schema.table" (one literal identifier with a dot in its name — a different and usually-nonexistent object). Accepts an array of parts OR a dot-separated string.

var b = require("blamejs");
b.safeSql.quoteQualified(["public", "users"]);
// → '"public"."users"'

b.safeSql.quoteQualified("public.users");
// → '"public"."users"'

b.safeSql.quoteQualified("dbA.public.users");
// → '"dbA"."public"."users"'

b.safeSql.quoteQualified(["app", "orders"], "mysql");
// → "`app`.`orders`"

b.safeSql.quoteList(names, dialect?, opts?) #

stable0.15.0
{
  allowReserved:  boolean,   // default: false — forwarded to quoteIdentifier
}

Quote a list of identifiers into a comma-joined fragment — each name validated + quoted via quoteIdentifier. The "many" companion to quoteIdentifier (one) and quoteQualified (a dotted name): use it for SELECT projections and INSERT column lists so the recurring cols.map(quoteIdentifier).join(", ") shape is composed, not hand-rolled.

There is deliberately NO value/string-literal quoter in this module: values flow as bound placeholders (? / $N), never interpolated, which is what makes the injection class structurally impossible. Quoting a literal would reopen it — use the query builder's parameter binding.

opts is forwarded to each quoteIdentifier (e.g. { allowReserved: true } for column lists that may contain SQL-keyword names, as b.sql does).

Throws SafeSqlError (sql/empty) on an empty array and (per quoteIdentifier) on any invalid identifier.

var b = require("blamejs");
b.safeSql.quoteList(["id", "createdAt"], "postgres");
// → '"id", "createdAt"'

b.safeSql.quoteList(["queueName", "status"], "mysql");
// → "`queueName`, `status`"

b.safeSql.assertOneOf(name, allowlist) #

stable0.1.0

Closed-allowlist gate — the strongest guarantee against identifier injection. allowlist is a Set or Array of permitted names; anything outside throws SafeSqlError with .code = "sql/not-allowed". Returns name on success so the call composes inline with quoteIdentifier. Use this whenever the operator-supplied identifier is drawn from a known finite set (which is most cases — table names are config, not user input).

var b = require("blamejs");
var ALLOWED = new Set(["audit_log", "consent_log", "session"]);

b.safeSql.assertOneOf("audit_log", ALLOWED);
// → "audit_log"

try { b.safeSql.assertOneOf("users", ALLOWED); }
catch (e) { e.code; }
// → "sql/not-allowed"

// Array form works too.
b.safeSql.assertOneOf("audit_log", ["audit_log", "consent_log"]);
// → "audit_log"

b.safeSql.countPlaceholders(sql) #

stable0.14.29

Count the bound ? placeholders in a SQL string, skipping any ? that appears inside a string literal ('...' / "...", doubled-quote escape aware) or inside a line or block comment. The canonical quote- and comment-aware scanner the query builder uses to check placeholder / param parity and the residency write-gate uses to align bound values; both compose this so the skip rules live in one place.

var b = require("blamejs");
b.safeSql.countPlaceholders("a = ? AND b = ?");
// → 2

b.safeSql.countPlaceholders("note = 'is ? literal' AND id = ?");
// → 1

b.safeSql.toPositional(sql, dialect) #

stable0.15.13

Rewrite bound ? placeholders to Postgres $N positional form, skipping any ? inside a string literal ('...' / "..." / ` ... , doubled-quote escape aware) or a line / block comment. For any non-Postgres dialect the SQL is returned unchanged (? is already the wire form). This is the same quote- and comment-aware scan as countPlaceholders`, extended to emit the rewritten string and to skip MySQL backtick-quoted identifiers; the query builder and the cluster store both compose it so the rewrite lives in one place.

var b = require("blamejs");
b.safeSql.toPositional("a = ? AND b = ?", "postgres");
// → "a = $1 AND b = $2"

b.safeSql.toPositional("note = 'is ? literal' AND id = ?", "postgres");
// → "note = 'is ? literal' AND id = $1"

b.safeSql.normalizeForScan(sql) #

stable0.17.4

Produce a parse-only copy of sql whose token boundaries are real whitespace, so a regex tokenizer that assumes whitespace-separated tokens cannot be evaded. SQL lets two tokens abut with NO whitespace whenever a comment OR a quoted-identifier boundary separates them (an INSERT whose quoted table name abuts INTO, or a slash-star comment wedged between a keyword and the table); a keyword/table detector hand-rolled with \s+ boundaries silently misses those forms even though the engine executes them. This scan replaces every line (--) and slash-star block comment with a single space and inserts a separating space wherever a quoted string / identifier ('...' / "..." / a backtick-quoted name) abuts a word character on either side — a word char directly before the opening quote OR directly after the closing quote. The same quote- and comment-aware single pass as countPlaceholders / toPositional (doubled-quote escapes respected), so a comment marker inside a string literal is copied verbatim, never collapsed. The executed SQL is unchanged — this copy only feeds a scanner.

var b = require("blamejs");
b.safeSql.normalizeForScan('INSERT INTO"t"(a) VALUES(?)');
// → 'INSERT INTO "t"(a) VALUES(?)'

b.safeSql.normalizeForScan('UPDATE"residents"SET x=1');
// → 'UPDATE "residents" SET x=1'

b.safeSql.normalizeForScan("SELECT 1-- note");
// → "SELECT 1 "

b.safeSql.DEFAULT_IDENTIFIER_RE #

stable0.1.0

The default identifier shape regex — /^[A-Za-z_][A-Za-z0-9_]*$/. Exposed so operator code that needs a slightly-wider or slightly-narrower shape can compose against it instead of re-deriving the pattern. ASCII-only by design — Unicode identifiers are dialect-specific and surface in mismatched-encoding footguns we don't want to default into.

var b = require("blamejs");
b.safeSql.DEFAULT_IDENTIFIER_RE.test("audit_log");
// → true

b.safeSql.DEFAULT_IDENTIFIER_RE.test("1starts_with_digit");
// → false

b.safeSql.MAX_IDENTIFIER_LENGTH #

stable0.1.0

Hard cap on identifier length — 63 characters. Matches Postgres' NAMEDATALEN default; SQLite and MySQL accept longer names but defaulting to the strictest dialect keeps cross-dialect SQL portable.

var b = require("blamejs");
b.safeSql.MAX_IDENTIFIER_LENGTH;
// → 63

b.safeSql.assertSingleStatement(sql, opts?) #

stable0.15.4
{
  label:     string,    // message prefix (default: "sql")
  makeError: function,  // (message, codeSuffix) => Error  (default: SafeSqlError "sql/")
}

The one quote/comment-aware single-statement gate for any FINISHED SQL string that reaches a driver. Refuses a NUL, a lone surrogate, a top-level ';' (stacked statement), an unterminated quote, and unbalanced parentheses - while CORRECTLY allowing those characters inside a balanced quoted label (e.g. a MySQL ENUM('a;b')). Hand-rolled DDL (schema reconcile, the DSR store, migrations) and the b.sql builder's own output gates route through this single scan so the injection backstop cannot drift between the structured builder and the raw-DDL paths. Returns the input string so a caller can wrap inline: runSql(db, safeSql.assertSingleStatement(ddl, { label: "schema" }));

var ddl = b.safeSql.assertSingleStatement("CREATE TABLE t (id INTEGER)", { label: "schema" });
// returns the input string; throws sql/stacked-statement on a stacked DDL

b.safeSql.assertNoRawStringLiteral(sql, where, makeError?) #

stable0.15.13

The one quote/comment-aware scan that refuses a '...' STRING LITERAL in raw SQL — the injection backstop for the b.sql builder's raw fragments and the external-db raw-query path, which must bind every value with a ? placeholder rather than splice a literal. Walks the SQL skipping "..." quoted identifiers (doubled-quote escapes handled), -- line comments, and slash-star block comments; on the first top-level ' it throws the caller's error (makeError(where) returns the Error to throw). Both b.sql and the external-db raw gate route through this single scan so a fix to the scanner cannot drift between them.

b.safeSql.assertNoRawStringLiteral("WHERE id = ?", "where");   // ok (no literal)
try { b.safeSql.assertNoRawStringLiteral("WHERE name = 'x'", "where"); }
catch (e) { e.code; }                                          // → "sql/raw-literal"

Last updated 2026-08-08T16:39:49.652Z by seeder.