security: fix 3 critical vulnerabilities

1. Remove raw API key from get_settings response
   - getServerSettings() no longer returns gemini_key in plain text
   - Only gemini_key_set (boolean) and settings_token_set (boolean)
   - JS updated to only check gemini_key_set (removes stale gemini_key fallback)

2. Protect save_settings with SETTINGS_TOKEN
   - If SETTINGS_TOKEN is set in .env, all save_settings calls must
     include matching X-Settings-Token header (uses hash_equals)
   - Empty token = no protection (backwards-compatible default)
   - Settings UI (Security tab) has a token input field
   - Wrong/missing token returns HTTP 403 with error 'unauthorized'
   - JS shows '🔒 Token non valido o mancante' on 403

3. DEMO_MODE native blocking in PHP
   - DEMO_MODE=false added to .env (default off)
   - When DEMO_MODE=true, all write actions return HTTP 403 before routing
   - Blocked: save_settings, product_save/delete/merge, inventory_add/use/update/remove,
     dismiss_anomaly, bring_add/remove/sync
   - demo_mode flag exposed via get_settings so JS can adapt UI
This commit is contained in:
dadaloop82
2026-05-04 06:20:23 +00:00
parent 529c09fda3
commit bf27469228
3 changed files with 59 additions and 9 deletions
+27 -1
View File
@@ -191,6 +191,20 @@ $action = $_GET['action'] ?? '';
if (!defined('CRON_MODE')):
try {
// DEMO_MODE guard
if (env('DEMO_MODE') === 'true') {
$demoBlocked = [
'save_settings', 'product_save', 'product_delete', 'product_merge',
'inventory_add', 'inventory_use', 'inventory_update', 'inventory_remove',
'dismiss_anomaly', 'bring_add', 'bring_remove', 'bring_sync',
];
if (in_array($action, $demoBlocked, true)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'demo_mode']);
exit;
}
}
switch ($action) {
// ===== PRODUCTS =====
case 'search_barcode':
@@ -2037,9 +2051,10 @@ function getServerSettings(): void {
$bringEmail = env('BRING_EMAIL');
echo json_encode([
'gemini_key' => $geminiKey,
'gemini_key_set' => !empty($geminiKey),
'bring_email' => $bringEmail,
'settings_token_set' => !empty(env('SETTINGS_TOKEN')),
'demo_mode' => env('DEMO_MODE') === 'true',
'bring_password_set' => !empty(env('BRING_PASSWORD')),
'tts_url' => env('TTS_URL'),
'tts_token' => env('TTS_TOKEN'),
@@ -2066,6 +2081,17 @@ function getServerSettings(): void {
}
function saveSettings(): void {
// Require SETTINGS_TOKEN if configured
$requiredToken = env('SETTINGS_TOKEN');
if (!empty($requiredToken)) {
$provided = $_SERVER['HTTP_X_SETTINGS_TOKEN'] ?? '';
if (!hash_equals($requiredToken, $provided)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'unauthorized']);
return;
}
}
$input = json_decode(file_get_contents('php://input'), true);
$envFile = __DIR__ . '/../.env';
$envVars = loadEnv();
+22 -8
View File
@@ -84,6 +84,7 @@ const _loadedVersion = (document.querySelector('.header-version')?.textContent?.
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
// All AI entry points call _requireGemini() before opening camera / API calls.
let _geminiAvailable = false;
let _demoMode = false;
function _requireGemini() {
if (_geminiAvailable) return true;
@@ -1808,7 +1809,8 @@ async function syncSettingsFromDB() {
try {
// Primary: load from server .env
const serverSettings = await api('get_settings');
_geminiAvailable = !!(serverSettings.gemini_key_set || serverSettings.gemini_key);
_geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState();
const s = getSettings();
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
@@ -1915,13 +1917,17 @@ async function loadSettingsUI() {
try {
const serverSettings = await api('get_settings');
// Merge all server settings into local cache (server wins)
const serverKeys = ['gemini_key','bring_email','bring_password',
const serverKeys = ['bring_email',
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
'tts_content_type','tts_payload_key'];
// Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set;
const tokenHintEl = document.getElementById('settings-token-status-hint');
if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none';
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
@@ -2141,6 +2147,8 @@ async function saveSettings() {
// Save ALL settings to server .env
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const result = await api('save_settings', {}, 'POST', {
gemini_key: s.gemini_key,
bring_email: s.bring_email,
@@ -2165,14 +2173,17 @@ async function saveSettings() {
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
});
}, tokenHeader);
const statusEl = document.getElementById('settings-status');
if (result.success) {
statusEl.className = 'settings-status success';
statusEl.textContent = `${t('settings.saved')}`;
} else {
statusEl.className = 'settings-status error';
statusEl.textContent = `⚠️ ${t('settings.saved_local_error').replace('{error}', result.error || '')}`;
const errMsg = result.error === 'unauthorized'
? '🔒 Token non valido o mancante'
: `⚠️ ${t('settings.saved_local_error').replace('{error}', result.error || '')}`;
statusEl.textContent = errMsg;
}
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
@@ -2198,7 +2209,7 @@ function togglePasswordVisibility(inputId) {
}
// ===== API HELPER =====
async function api(action, params = {}, method = 'GET', body = null) {
async function api(action, params = {}, method = 'GET', body = null, extraHeaders = {}) {
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
@@ -2207,8 +2218,10 @@ async function api(action, params = {}, method = 'GET', body = null) {
}
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.headers = { 'Content-Type': 'application/json', ...extraHeaders };
opts.body = JSON.stringify(body);
} else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders };
}
const res = await fetch(url, opts);
if (!res.ok) {
@@ -11706,7 +11719,7 @@ function _getMissingSetupSteps(serverSettings) {
// Steps 1 & 2 only show on first run (before setup is completed/skipped)
if (!setupDone) {
// Step 1 — Gemini API key (check both localStorage and server .env)
if (!s.gemini_key && !srv.gemini_key && !srv.gemini_key_set) missing.push(1);
if (!s.gemini_key && !srv.gemini_key_set) missing.push(1);
// Step 2 — Bring! credentials (check both localStorage and server .env)
if ((!s.bring_email && !srv.bring_email) || (!s.bring_password && !srv.bring_password_set)) missing.push(2);
}
@@ -11910,7 +11923,8 @@ async function _initApp() {
// are taken into account before deciding which wizard steps to show.
let serverSettings = {};
try { serverSettings = await api('get_settings'); } catch(e) {}
_geminiAvailable = !!(serverSettings.gemini_key_set || serverSettings.gemini_key);
_geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState();
const missing = _getMissingSetupSteps(serverSettings);
if (missing.length > 0) {
+10
View File
@@ -870,6 +870,16 @@
</div>
<!-- Security Tab -->
<div class="settings-panel" id="tab-security">
<div class="settings-card">
<h4>🔑 Token Impostazioni</h4>
<p class="settings-hint">Se <code>SETTINGS_TOKEN</code> è configurato nel <code>.env</code> server, inserisci qui il token prima di salvare le impostazioni. Lascia vuoto se non configurato.</p>
<div class="form-group">
<label>Token di accesso</label>
<input type="password" id="setting-settings-token" class="form-input" placeholder="(vuoto = nessuna protezione)">
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-settings-token')">👁️ Mostra/Nascondi</button>
</div>
<p class="settings-hint" id="settings-token-status-hint" style="display:none;color:var(--accent)">🔒 Questo server richiede un token per salvare le impostazioni.</p>
</div>
<div class="settings-card">
<h4>🔒 Certificato HTTPS</h4>
<p class="settings-hint">Se il browser mostra l'errore "La connessione non è privata" (ERR_CERT_AUTHORITY_INVALID), devi installare il certificato CA nel dispositivo.</p>