From ca9df371c2eee89b48501a43d95a30cffffafeaa Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 14 Apr 2026 05:15:55 +0200 Subject: [PATCH] Enforce server-side LLM model whitelist + scope idle timeout to writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review: 1. callAI() previously accepted any model string from the client. POST /api/hpi with { model: "openai/o1" } would call the reasoning model regardless of whether the operator enabled it. Added getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed cache) and a guard at the top of callAI() that rejects with "model_not_permitted" when the requested ID isn't in the active roster. No model supplied → silent fallback to DEFAULT_MODEL. 2. Middleware was updating user_sessions.last_activity on every request, including GETs. Client-side polling (/api/auth/me heartbeats, dashboard refreshes, log tail calls) kept sessions alive indefinitely, defeating the 24h sliding idle policy. Now only POST/PUT/DELETE/PATCH count as "user activity". GETs are read-only and often automated — they no longer extend the session. Idle enforcement still runs on every method, so a 24h-idle user still gets kicked on their next GET. --- src/middleware/auth.js | 9 +++++++-- src/utils/ai.js | 26 +++++++++++++++++++++++++- src/utils/models.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index c6287c4..b7fd4ad 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -78,8 +78,13 @@ async function authMiddleware(req, res, next) { return res.status(401).json({ error: 'Session expired due to inactivity', idleTimeout: true }); } - // Throttled last_activity update — at most every 5 min - if (idleMs > ACTIVITY_THROTTLE_MS) { + // Only state-changing HTTP methods count as "user activity" for + // sliding-idle purposes. GET requests are often automated (poll + // loops, dashboards, /api/auth/me heartbeats) and would otherwise + // keep a session alive forever, defeating the 24h idle policy. + var isWriteMethod = req.method === 'POST' || req.method === 'PUT' + || req.method === 'DELETE' || req.method === 'PATCH'; + if (isWriteMethod && idleMs > ACTIVITY_THROTTLE_MS) { db.run('UPDATE user_sessions SET last_activity = NOW() WHERE id = ?', [session.id]).catch(function() {}); // Sliding cookie refresh (web only) — extend browser-side lifetime too if (!mobile && req.cookies && req.cookies.ped_auth) { diff --git a/src/utils/ai.js b/src/utils/ai.js index 53b3ea1..e091d23 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -400,11 +400,35 @@ async function callLiteLLM(messages, model, temperature, maxTokens) { // ============================================================ async function callAI(messages, options) { options = options || {}; - var model = options.model || DEFAULT_MODEL; + var requestedModel = options.model; + var model = requestedModel || DEFAULT_MODEL; var temperature = options.temperature || 0.3; var maxTokens = options.maxTokens || 4000; var startTime = Date.now(); + // Server-side whitelist: reject any model the operator hasn't enabled. + // Prevents a client from passing e.g. model:"openai/o1" and draining + // the budget on a reasoning model outside the configured roster. + // Falls back silently to DEFAULT_MODEL when no model was provided. + if (requestedModel) { + try { + var db = require('../db/database'); + var { getAllowedModelIds } = require('./models'); + var allowed = await getAllowedModelIds(db); + if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) { + var err = new Error('Model not permitted'); + err.code = 'model_not_permitted'; + err.model = requestedModel; + throw err; + } + } catch (e) { + if (e && e.code === 'model_not_permitted') throw e; + // DB lookup failure — fall through, let the provider reject if it + // doesn't recognize the model. Log for visibility. + console.warn('[callAI] Allow-list check skipped:', e && e.message); + } + } + try { var result; diff --git a/src/utils/models.js b/src/utils/models.js index 9ee5877..eb938b7 100644 --- a/src/utils/models.js +++ b/src/utils/models.js @@ -222,6 +222,45 @@ async function getAvailableModelsWithOverrides(db) { } } +// Whitelist of allowed model IDs, cached in memory. Refreshed periodically +// from the DB so admin changes propagate without restart. Prevents +// client-supplied model values (e.g. POST /api/hpi with model="openai/o1") +// from calling expensive models outside the configured roster. +var ALLOWED_MODELS_CACHE = null; +var ALLOWED_MODELS_FETCHED = 0; +var ALLOWED_MODELS_TTL_MS = 60 * 1000; + +async function getAllowedModelIds(db) { + var now = Date.now(); + if (ALLOWED_MODELS_CACHE && (now - ALLOWED_MODELS_FETCHED) < ALLOWED_MODELS_TTL_MS) { + return ALLOWED_MODELS_CACHE; + } + try { + var models = await getAvailableModelsWithOverrides(db); + var ids = new Set((models || []).map(function(m) { return m.id; })); + ALLOWED_MODELS_CACHE = ids; + ALLOWED_MODELS_FETCHED = now; + return ids; + } catch (e) { + // Fall back to static list if DB fetch fails + var staticIds = new Set(getAvailableModels().map(function(m) { return m.id; })); + return staticIds; + } +} + +// Synchronous check against the static baseline (no DB access). Used when +// the async-aware helper is inconvenient. For full enforcement use +// getAllowedModelIds + Set.has() in the route before invoking callAI. +function isStaticAllowedModel(id) { + if (!id) return false; + return getAvailableModels().some(function(m) { return m.id === id; }); +} + +function invalidateAllowedModelsCache() { + ALLOWED_MODELS_CACHE = null; + ALLOWED_MODELS_FETCHED = 0; +} + module.exports = { AVAILABLE_MODELS, DEFAULT_MODEL, @@ -233,6 +272,9 @@ module.exports = { LITELLM_MODELS, getAvailableModels, getAvailableModelsWithOverrides, + getAllowedModelIds, + isStaticAllowedModel, + invalidateAllowedModelsCache, getDefaultModel, getFallbackModel, getBedrockModelId,