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:
+27
-1
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user