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:
parent
f12bdb0fa4
commit
ca9df371c2
3 changed files with 74 additions and 3 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue