From b6cb94fdb5d83acb131373a92838d50f98c94b6b Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 22:31:08 +0200 Subject: [PATCH] Einstellungen strikt pro Benutzer: kein process.env-Fallback mehr config.get() ist bei einem nicht gesetzten Schluessel auf process.env zurueckgefallen. Da die Env-Variablen die Konfiguration des Admins enthalten (Docker-Env: MAIL_*, CALDAV_URL, OLLAMA_API_KEY, API_TOKEN), hat damit JEDER neu angelegte Benutzer ohne eigene Einstellungen stillschweigend die Zugangsdaten des Admins geerbt: - /einstellungen zeigte ihm die Zugangsdaten des Admins an. - mailer/caldav isConfigured() war true -> der IMAP-Poller hat fuer den neuen Benutzer das Postfach des Admins abgerufen und dessen E-Mails in sein Konto einsortiert; CalDAV synchronisierte den Kalender des Admins. - Der bezahlte Ollama-Key des Admins wurde mitbenutzt. Jetzt: - config.get() loest ausschliesslich die Zeilen des aktuellen Benutzers auf, sonst den eingebauten Standard (nicht-geheime Werte wie Modell, Host, Ports, Intervalle). Alle Credentials sind bei neuen Benutzern leer, d. h. Ollama/E-Mail/CalDAV/API sind fuer sie aus, bis sie sich selbst etwas eintragen. - Noch per Env gesetzte Konfiguration wird einmalig in die Zeilen des ADMIN uebernommen (importEnvIntoAdmin, Aufruf beim Boot nachdem app_state existiert - in runMigration war das bei Neuinstallationen ein No-op, weil die Tabelle dort noch nicht angelegt ist). - config.ensureLoaded(user.id) beim Aufloesen der Session bzw. des X-API-Key. config.get() ist synchron und liest den Per-User-Cache; ohne Warmladen las ein Web-Request die Werte als "nicht konfiguriert". Das hat bisher der env-Fallback verdeckt (er hielt zufaellig die Werte des Admins) - ohne ihn muss die Config pro Request wirklich geladen werden. Verifiziert gegen eine Kopie der Produktions-DB mit Sentinel-Env-Werten: Admin behaelt seine kompletten Einstellungen, der zweite Benutzer sieht ueberall leere Credentials, Mail/CalDAV sind fuer ihn inaktiv, und der Env-API-Token wird nicht mehr als gueltiger X-API-Key akzeptiert. Co-Authored-By: Claude Opus 4.8 --- lib/api.js | 6 ++++++ lib/config.js | 41 ++++++++++++++++++++++++++++------------ lib/migrate-multiuser.js | 20 ++++++++++++++++++-- server.js | 22 +++++++++++++++++---- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/lib/api.js b/lib/api.js index 9cb3b53..faedecb 100644 --- a/lib/api.js +++ b/lib/api.js @@ -14,6 +14,7 @@ const blacklist = require('./blacklist'); const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels'); const { normalizeDokumente } = require('./documents'); const { userContext, currentUserId } = require('./context'); +const config = require('./config'); const CONFIG_PREFIX = 'cfg:'; @@ -76,6 +77,11 @@ function createExternalApi(deps) { return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' }); } req.user = user; + // Warm this user's cfg rows: config.get() is synchronous and reads from + // the per-user cache, so without this an API request could see the user + // as unconfigured (e.g. no Ollama key) purely because nothing had loaded + // their rows yet in this process. + await config.ensureLoaded(user.id); // Run the remainder of the request inside this user's context so // currentUserId() / config.get() / the scoped helpers all resolve here. userContext.run(user, next); diff --git a/lib/config.js b/lib/config.js index 53505ad..2abf165 100644 --- a/lib/config.js +++ b/lib/config.js @@ -8,10 +8,15 @@ // context (lib/context.js). An edit therefore takes effect immediately, scoped // to the user who made it — no restart, no .env file. // -// On first start of an install that previously used .env, init() migrates any -// still-present env value into the *admin* user's config once. After that the -// database is the single source of truth; process.env is only a fallback for keys -// that were never saved. +// On first start of an install that previously used .env (or that still passes +// the values as container env vars), the still-present env values are imported +// into the *admin* user's rows once — see importEnvIntoAdmin(), called from +// server.js at boot. After that the database is the single source of truth. +// +// process.env is deliberately NOT a read fallback: env config belongs to the +// admin, so falling back to it would hand every freshly created user the admin's +// mailbox, calendar, Ollama key and API token. Unset keys resolve to the +// built-in defaults instead, which leaves a new user's settings empty. const { currentUser } = require('./context'); @@ -95,9 +100,16 @@ const loaded = new Set(); // userIds whose cfg rows have been read from the DB let dbAllFn = null; let dbRunFn = null; -function envOrDefault(key) { - const e = process.env[key]; - return e && e.length ? e : DEFAULTS[key]; +// The built-in default for a key. Deliberately NOT a process.env lookup: env +// values belong to the admin (they are imported into the admin's rows once at +// boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back +// to process.env, every user without their own row would silently inherit the +// admin's credentials — their own mailbox, calendar, Ollama key and API token. +// The defaults below are non-secret standards only (model, host, ports, poll +// intervals); every credential defaults to empty, so a new user starts with +// Ollama/E-Mail/CalDAV/API switched off until they configure their own. +function builtinDefault(key) { + return DEFAULTS[key]; } // Load one user's cfg rows from the DB into the cache. No-op if already loaded. @@ -117,15 +129,20 @@ function invalidate(userId) { cache.delete(userId); } -// Synchronous read for the current user. Falls back to process.env (pre-migration -// / never saved) then to the built-in default. Outside a request context (boot) -// only the env/default fallback applies — callers that need a specific user must -// run inside the user context (see lib/context.js). +// Synchronous read for the *current user only*. A key the user has not stored +// falls back to the built-in default — never to another user's value and never +// to process.env. A user who has configured nothing therefore reads as "empty" +// (no mail host, no keys), which is exactly what isolates them: mailer/caldav +// isConfigured() turns false and their background jobs stay idle. +// +// Outside a request context (boot) there is no user, so only the defaults apply. +// Callers that need a specific user's values must run inside that user's context +// (see lib/context.js) and must have called ensureLoaded(userId) first. function get(key) { const u = currentUser(); const userObj = u ? cache.get(u.id) : null; if (userObj && userObj[key] !== undefined) return userObj[key]; - return envOrDefault(key); + return builtinDefault(key); } // All keys with their effective values for the current user — used by the diff --git a/lib/migrate-multiuser.js b/lib/migrate-multiuser.js index a5d22b5..a26740c 100644 --- a/lib/migrate-multiuser.js +++ b/lib/migrate-multiuser.js @@ -242,12 +242,28 @@ async function runMigration({ db, dbAll, dbGet, dbRun }) { // 5. Move on-disk files into a per-user subdirectory for the admin ----- moveFilesIntoUserSubdir(adminId); - // 6. One-time .env -> admin cfg migration ------------------------------ + // 6. One-time env -> admin cfg import ---------------------------------- + // On an upgraded install app_state already exists here, so this fills the + // admin's rows right away. On a *fresh* install the table is only created + // after this migration returns, so this is a no-op — server.js therefore + // calls importEnvIntoAdmin() again once the schema is complete. Idempotent + // either way (it only fills keys the admin has not stored). await migrateEnvForAdmin(dbAll, adminId); return { adminId }; } +// Import any still-present env config into the admin's rows. Called at boot from +// server.js, after initializeDatabase() has created app_state — this is what +// makes a deployment that passes its config as container env vars (as ours does) +// end up with those values owned by the admin, instead of leaking to every user +// through a read-time process.env fallback (which config.get() no longer has). +async function importEnvIntoAdmin({ dbAll, dbGet }) { + const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]); + if (!admin) return; + await migrateEnvForAdmin(dbAll, admin.id); +} + // Rename `table` to `table_old`, create the new table from `newSchemaSql`, // copy rows via `copySql` (with `copyParams`), then drop `table_old`. async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) { @@ -308,4 +324,4 @@ async function migrateEnvForAdmin(dbAll, adminId) { } } -module.exports = { runMigration, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD }; \ No newline at end of file +module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD }; \ No newline at end of file diff --git a/server.js b/server.js index 9955e9f..ede5faf 100644 --- a/server.js +++ b/server.js @@ -45,7 +45,7 @@ const config = require('./lib/config'); const { userContext, currentUser, currentUserId } = require('./lib/context'); const password = require('./lib/password'); const migrate = require('./lib/migrate-multiuser'); -const { runMigration } = migrate; +const { runMigration, importEnvIntoAdmin } = migrate; const app = express(); const PORT = process.env.PORT || 3000; @@ -110,6 +110,12 @@ app.use(async (req, res, next) => { const user = await loadSessionUser(cookies[SESSION_COOKIE]); req.user = user; res.locals.user = user; + // Warm this user's cfg rows before anything reads them: config.get() is + // synchronous and answers from the per-user cache, so a user whose rows were + // never loaded would silently read as "unconfigured". Until now the process + // .env fallback papered over that (it happened to hold the admin's values); + // with the fallback gone, the config must actually be loaded per request. + if (user) await config.ensureLoaded(user.id); userContext.run(user, next); } catch (e) { console.error('Session-Laden fehlgeschlagen:', e.message); @@ -1216,11 +1222,19 @@ async function initializeDatabase() { initializeDatabase().then(async () => { console.log('Database initialized successfully'); - // Load configuration from the DB (migrates any still-present .env values - // once). Must run before the boot checks below (mailer/caldav configured?) and - // before any route that reads config — values live in the DB now, not in .env. + // Load configuration from the DB. Must run before the boot checks below + // (mailer/caldav configured?) and before any route that reads config — values + // live in the DB now, not in the environment. await config.init({ dbAll, dbRun }); + // Import any config still supplied via the environment into the *admin's* rows + // (one-time, idempotent). Env config is the admin's: config.get() has no + // process.env fallback, precisely so that a newly created user does not + // inherit the admin's mailbox, calendar, Ollama key and API token. This runs + // here rather than inside runMigration() because on a fresh install app_state + // does not exist yet while the migration is running. + await importEnvIntoAdmin({ dbAll, dbGet }); + // Current user's id — set by the auth middleware (lib/context.js). Guaranteed // to be present inside any protected route or background-per-user task. const uid = () => currentUserId();