harden everything more

This commit is contained in:
Your Name 2026-08-26 12:08:34 -04:00
parent bba2500473
commit 389c1c1cdc
2 changed files with 70 additions and 2 deletions

67
db.js
View file

@ -71,7 +71,71 @@ function getUserById(id) {
const dbCache = new LRUCache({ max: 25 });
// User-supplied SQL only ever runs against one database file. SQLite's ATTACH
// lets a single connection open ANY other file on disk — dbs/0.sqlite (the prime
// db: users, sessions, password hashes, every structure's metadata) or any other
// tenant's dbs/N.sqlite — so it is a cross-tenant read/write primitive we must
// deny on every handle we hand to structure code (repl, db library, route + WS
// handlers all get their `sql` from getDbInstance). better-sqlite3 13.x exposes
// no authorizer and no attach-limit, so we screen the SQL text. SQLite has no
// dynamic-SQL exec, so a statement can only ATTACH if the keyword appears
// literally in the text passed here — after we strip the only places it could
// otherwise hide (comments and quoted literals/identifiers), a bare ATTACH/DETACH
// token is unambiguous. That makes this a complete gate, not a heuristic filter.
function assertNoAttach(sql) {
if (typeof sql !== "string") return;
const stripped = sql
.replace(/--[^\n]*/g, " ") // line comments
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/'(?:[^']|'')*'/g, " ") // '...' string literals
.replace(/"(?:[^"]|"")*"/g, " ") // "..." quoted identifiers
.replace(/\[[^\]]*\]/g, " ") // [...] quoted identifiers
.replace(/`(?:[^`]|``)*`/g, " "); // `...` quoted identifiers
if (/\b(?:attach|detach)\b/i.test(stripped)) {
throw new Error("ATTACH/DETACH is not allowed on a Bliss database");
}
}
// Methods that take a SQL string as their first argument — screen it.
const SQL_ENTRYPOINTS = new Set(["prepare", "exec", "pragma"]);
// Methods that reach outside the single db file entirely — never expose them.
const BLOCKED_METHODS = new Set(["loadExtension", "backup", "serialize"]);
// Wrap a raw connection so structure code can use it normally but cannot escape
// its one file. Everything passes through untouched except: SQL entrypoints are
// screened for ATTACH, and file-reaching methods throw. A Proxy (rather than a
// hand-rolled facade) keeps every other property/method — .transaction,
// .function, .inTransaction, .name, etc. — working exactly as before.
function guardConnection(raw) {
return new Proxy(raw, {
get(target, prop, receiver) {
if (BLOCKED_METHODS.has(prop)) {
return () => {
throw new Error(`${String(prop)} is disabled on a Bliss database`);
};
}
const value = target[prop];
if (typeof value !== "function") return value;
if (SQL_ENTRYPOINTS.has(prop)) {
return function (sql, ...rest) {
assertNoAttach(sql);
return value.call(target, sql, ...rest);
};
}
return value.bind(target);
},
});
}
function getDbInstance(dbId) {
// The prime db is never a user database. Kernel code uses the module-level
// `db` handle above; nothing legitimately fetches prime through here. Refuse
// it outright so a prime connection can never leak into user code even if an
// upstream access check regresses — defense-in-depth behind the ATTACH guard.
if (Number(dbId) === PRIME_DB_ID) {
throw new Error("the prime db is not accessible");
}
let dbInstance = dbCache.get(dbId);
if (!dbInstance) {
@ -80,7 +144,8 @@ function getDbInstance(dbId) {
dbCache.set(dbId, dbInstance);
}
return dbInstance;
// Never hand out the raw connection: it could ATTACH another tenant's file.
return guardConnection(dbInstance);
}
function getAllRoutes() {