From 389c1c1cdc6c04dd4ba3f1553f492b77fef31a6f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 26 Aug 2026 12:08:34 -0400 Subject: [PATCH] harden everything more --- db.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- index.js | 5 ++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/db.js b/db.js index 183a032..a3a9f99 100644 --- a/db.js +++ b/db.js @@ -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() { diff --git a/index.js b/index.js index a636bcf..2890d97 100644 --- a/index.js +++ b/index.js @@ -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") {