fix: patch nodemailer; Settings offers STT models the gateway really has

Security
- nodemailer 9.0.1 -> 9.1.1, clearing four high advisories, two of which
  are delivery bugs that matter for an app that sends mail: recipient-domain
  validation bypass via RFC 5322 comments, and an IDN/punycode allow-list
  bypass, both of which can route mail to an attacker-controlled domain.

Live transcription
- The Settings picker was a hardcoded list of six ids
  (local-whisper-*, local-parakeet-v3, gemini-*). None of them resolve on
  this gateway, and /api/transcribe prefers the user's choice over the admin
  default, so picking one broke every recording with "Invalid model name".
  Verified against the live gateway: local-whisper-large-v3-turbo -> 400.
- The picker now lists what /model/info advertises as audio_transcription,
  cached for five minutes, with the built-in list kept only as a fallback
  and the admin default marked.
- The pipeline itself is healthy: local-kokoro-tts produced 92KB of speech
  and mistral-voxtral-mini-transcribe returned the sentence back verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 15:54:57 +02:00
parent 31abddb6e6
commit f0f48a3578
5 changed files with 61 additions and 7 deletions

8
package-lock.json generated
View file

@ -32,7 +32,7 @@
"marked": "^18.0.2",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
"nodemailer": "9.0.1",
"nodemailer": "^9.1.1",
"openai": "^4.73.0",
"openid-client": "^6.8.2",
"pdf-parse": "^1.1.1",
@ -5958,9 +5958,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"

View file

@ -40,7 +40,7 @@
"marked": "^18.0.2",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
"nodemailer": "9.0.1",
"nodemailer": "^9.1.1",
"openai": "^4.73.0",
"openid-client": "^6.8.2",
"pdf-parse": "^1.1.1",

View file

@ -6,7 +6,7 @@ var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getSTTModelLists, getSTTProvider, discoverSTTModels } = require('../utils/sttProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware);
@ -57,7 +57,16 @@ router.get('/preferences/options', async function(req, res) {
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
// Offer what the gateway really has. The built-in list is a last resort:
// its ids do not resolve on every deployment, and a user who picked one got
// "Invalid model name" on every recording, because the user's choice wins
// over the admin default in /api/transcribe.
var sttIds = await discoverSTTModels();
if (!sttIds.length) sttIds = getSTTModelLists().litellm.slice();
var adminSttModel = await db.getSetting('stt.model') || process.env.LITELLM_STT_MODEL || '';
var sttModels = sttIds.map(function(model) {
return { value: model, label: model + (model === adminSttModel ? ' (default)' : '') };
});
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; });
res.json({

View file

@ -33,8 +33,35 @@ function getLiteLLMSTTModels(models) {
.map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); });
}
// What the gateway actually offers, so Settings cannot present a user with a
// model that does not exist. The hardcoded list above is only a last resort:
// on this deployment none of its six ids resolve, and picking one returns
// "Invalid model name" from /audio/transcriptions.
var sttDiscoveryCache = { at: 0, models: [] };
var STT_DISCOVERY_TTL_MS = 5 * 60 * 1000;
async function discoverSTTModels(options) {
var now = Date.now();
var fresh = !(options && options.force) && (now - sttDiscoveryCache.at) < STT_DISCOVERY_TTL_MS;
if (fresh && sttDiscoveryCache.models.length) return sttDiscoveryCache.models.slice();
if (getSTTProvider() !== 'litellm' || !process.env.LITELLM_API_BASE) return [];
try {
var axios = require('axios');
var { getLiteLLMAdminHeaders } = require('./litellm');
var base = String(process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
var resp = await axios.get(base + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
var ids = getLiteLLMSTTModels(resp.data && resp.data.data);
if (ids.length) sttDiscoveryCache = { at: now, models: ids };
return ids;
} catch (e) {
// A gateway hiccup must not empty the picker; the caller falls back.
return sttDiscoveryCache.models.slice();
}
}
module.exports = {
LITELLM_STT_MODELS,
discoverSTTModels,
getSTTDependencies,
getLiteLLMSTTModels,
getSTTModelLists,

View file

@ -75,3 +75,21 @@ test('audio backup settings render without dynamic HTML templates', () => {
assert.match(renderer, /document\.createElement\('button'\)/);
assert.match(renderer, /textContent =/);
});
// The Settings picker offered six hardcoded ids. On this gateway none of them
// resolve, and /api/transcribe prefers the user's choice over the admin
// default — so choosing one broke every recording with "Invalid model name".
test('the STT picker offers what the gateway has, not a hardcoded list', () => {
const stt = read('src/utils/sttProvider.js');
const prefs = read('src/routes/userPreferences.js');
assert.match(stt, /async function discoverSTTModels\(options\)/);
assert.match(stt, /getLiteLLMSTTModels\(resp\.data && resp\.data\.data\)/, 'filtered by audio_transcription mode');
assert.match(stt, /STT_DISCOVERY_TTL_MS = 5 \* 60 \* 1000;/, 'cached, so a user-facing page does not hit the gateway every load');
assert.match(stt, /module\.exports = \{[\s\S]{0,80}discoverSTTModels,/);
assert.match(prefs, /var sttIds = await discoverSTTModels\(\);/);
assert.match(prefs, /if \(!sttIds\.length\) sttIds = getSTTModelLists\(\)\.litellm\.slice\(\);/,
'the built-in list survives only as a fallback');
assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked');
});