harden everything more
This commit is contained in:
parent
bba2500473
commit
389c1c1cdc
2 changed files with 70 additions and 2 deletions
67
db.js
67
db.js
|
|
@ -71,7 +71,71 @@ function getUserById(id) {
|
||||||
|
|
||||||
const dbCache = new LRUCache({ max: 25 });
|
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) {
|
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);
|
let dbInstance = dbCache.get(dbId);
|
||||||
|
|
||||||
if (!dbInstance) {
|
if (!dbInstance) {
|
||||||
|
|
@ -80,7 +144,8 @@ function getDbInstance(dbId) {
|
||||||
dbCache.set(dbId, dbInstance);
|
dbCache.set(dbId, dbInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbInstance;
|
// Never hand out the raw connection: it could ATTACH another tenant's file.
|
||||||
|
return guardConnection(dbInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllRoutes() {
|
function getAllRoutes() {
|
||||||
|
|
|
||||||
5
index.js
5
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
|
// the whole editor: a private structure 404s for anyone who isn't on its member
|
||||||
// list, exactly like its user-facing routes do.
|
// list, exactly like its user-facing routes do.
|
||||||
app.param("structure_id", (req, res, next, id) => {
|
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
|
// 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.
|
// non-members stay 404 so we never confirm the structure exists to them.
|
||||||
if (!req.session.userId && req.method === "GET") {
|
if (!req.session.userId && req.method === "GET") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue