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() {

View file

@ -100,7 +100,10 @@ app.use("/plumbing", require("./plumbing"));
// the whole editor: a private structure 404s for anyone who isn't on its member
// list, exactly like its user-facing routes do.
app.param("structure_id", (req, res, next, id) => {
if (!model.canAccessStructure(req.session.userId, id)) {
// The prime structure (id 0) is kernel infrastructure: its db holds users,
// sessions and every structure's metadata. It is never editable through the
// workshop by anyone, logged in or not — treat it as if it doesn't exist.
if (Number(id) === 0 || !model.canAccessStructure(req.session.userId, id)) {
// Logged-out visitors get a chance to sign in and come back; logged-in
// non-members stay 404 so we never confirm the structure exists to them.
if (!req.session.userId && req.method === "GET") {