Enforce server-side LLM model whitelist + scope idle timeout to writes

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.
This commit is contained in:
Daniel 2026-04-14 05:15:55 +02:00
parent f12bdb0fa4
commit ca9df371c2
3 changed files with 74 additions and 3 deletions

View file

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

View file

@ -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;

View file

@ -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,