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')):
|
if (!defined('CRON_MODE')):
|
||||||
try {
|
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) {
|
switch ($action) {
|
||||||
// ===== PRODUCTS =====
|
// ===== PRODUCTS =====
|
||||||
case 'search_barcode':
|
case 'search_barcode':
|
||||||
@@ -2037,9 +2051,10 @@ function getServerSettings(): void {
|
|||||||
$bringEmail = env('BRING_EMAIL');
|
$bringEmail = env('BRING_EMAIL');
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'gemini_key' => $geminiKey,
|
|
||||||
'gemini_key_set' => !empty($geminiKey),
|
'gemini_key_set' => !empty($geminiKey),
|
||||||
'bring_email' => $bringEmail,
|
'bring_email' => $bringEmail,
|
||||||
|
'settings_token_set' => !empty(env('SETTINGS_TOKEN')),
|
||||||
|
'demo_mode' => env('DEMO_MODE') === 'true',
|
||||||
'bring_password_set' => !empty(env('BRING_PASSWORD')),
|
'bring_password_set' => !empty(env('BRING_PASSWORD')),
|
||||||
'tts_url' => env('TTS_URL'),
|
'tts_url' => env('TTS_URL'),
|
||||||
'tts_token' => env('TTS_TOKEN'),
|
'tts_token' => env('TTS_TOKEN'),
|
||||||
@@ -2066,6 +2081,17 @@ function getServerSettings(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings(): 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);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
$envVars = loadEnv();
|
$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.
|
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
|
||||||
// All AI entry points call _requireGemini() before opening camera / API calls.
|
// All AI entry points call _requireGemini() before opening camera / API calls.
|
||||||
let _geminiAvailable = false;
|
let _geminiAvailable = false;
|
||||||
|
let _demoMode = false;
|
||||||
|
|
||||||
function _requireGemini() {
|
function _requireGemini() {
|
||||||
if (_geminiAvailable) return true;
|
if (_geminiAvailable) return true;
|
||||||
@@ -1808,7 +1809,8 @@ async function syncSettingsFromDB() {
|
|||||||
try {
|
try {
|
||||||
// Primary: load from server .env
|
// Primary: load from server .env
|
||||||
const serverSettings = await api('get_settings');
|
const serverSettings = await api('get_settings');
|
||||||
_geminiAvailable = !!(serverSettings.gemini_key_set || serverSettings.gemini_key);
|
_geminiAvailable = !!(serverSettings.gemini_key_set);
|
||||||
|
_demoMode = !!serverSettings.demo_mode;
|
||||||
_updateGeminiButtonState();
|
_updateGeminiButtonState();
|
||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
||||||
@@ -1915,13 +1917,17 @@ async function loadSettingsUI() {
|
|||||||
try {
|
try {
|
||||||
const serverSettings = await api('get_settings');
|
const serverSettings = await api('get_settings');
|
||||||
// Merge all server settings into local cache (server wins)
|
// 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',
|
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
|
||||||
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
|
||||||
'camera_facing','scale_enabled','scale_gateway_url',
|
'camera_facing','scale_enabled','scale_gateway_url',
|
||||||
'meal_plan_enabled',
|
'meal_plan_enabled',
|
||||||
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
|
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
|
||||||
'tts_content_type','tts_payload_key'];
|
'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;
|
let changed = false;
|
||||||
for (const key of serverKeys) {
|
for (const key of serverKeys) {
|
||||||
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
|
||||||
@@ -2141,6 +2147,8 @@ async function saveSettings() {
|
|||||||
|
|
||||||
// Save ALL settings to server .env
|
// Save ALL settings to server .env
|
||||||
try {
|
try {
|
||||||
|
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
|
||||||
|
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
|
||||||
const result = await api('save_settings', {}, 'POST', {
|
const result = await api('save_settings', {}, 'POST', {
|
||||||
gemini_key: s.gemini_key,
|
gemini_key: s.gemini_key,
|
||||||
bring_email: s.bring_email,
|
bring_email: s.bring_email,
|
||||||
@@ -2165,14 +2173,17 @@ async function saveSettings() {
|
|||||||
tts_auth_type: s.tts_auth_type,
|
tts_auth_type: s.tts_auth_type,
|
||||||
tts_content_type: s.tts_content_type,
|
tts_content_type: s.tts_content_type,
|
||||||
tts_payload_key: s.tts_payload_key,
|
tts_payload_key: s.tts_payload_key,
|
||||||
});
|
}, tokenHeader);
|
||||||
const statusEl = document.getElementById('settings-status');
|
const statusEl = document.getElementById('settings-status');
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
statusEl.className = 'settings-status success';
|
statusEl.className = 'settings-status success';
|
||||||
statusEl.textContent = `✅ ${t('settings.saved')}`;
|
statusEl.textContent = `✅ ${t('settings.saved')}`;
|
||||||
} else {
|
} else {
|
||||||
statusEl.className = 'settings-status error';
|
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';
|
statusEl.style.display = 'block';
|
||||||
setTimeout(() => statusEl.style.display = 'none', 4000);
|
setTimeout(() => statusEl.style.display = 'none', 4000);
|
||||||
@@ -2198,7 +2209,7 @@ function togglePasswordVisibility(inputId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== API HELPER =====
|
// ===== 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}`;
|
let url = `${API_BASE}?action=${action}`;
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
@@ -2207,8 +2218,10 @@ async function api(action, params = {}, method = 'GET', body = null) {
|
|||||||
}
|
}
|
||||||
const opts = { method };
|
const opts = { method };
|
||||||
if (body) {
|
if (body) {
|
||||||
opts.headers = { 'Content-Type': 'application/json' };
|
opts.headers = { 'Content-Type': 'application/json', ...extraHeaders };
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
|
} else if (Object.keys(extraHeaders).length > 0) {
|
||||||
|
opts.headers = { ...extraHeaders };
|
||||||
}
|
}
|
||||||
const res = await fetch(url, opts);
|
const res = await fetch(url, opts);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -11706,7 +11719,7 @@ function _getMissingSetupSteps(serverSettings) {
|
|||||||
// Steps 1 & 2 only show on first run (before setup is completed/skipped)
|
// Steps 1 & 2 only show on first run (before setup is completed/skipped)
|
||||||
if (!setupDone) {
|
if (!setupDone) {
|
||||||
// Step 1 — Gemini API key (check both localStorage and server .env)
|
// 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)
|
// 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);
|
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.
|
// are taken into account before deciding which wizard steps to show.
|
||||||
let serverSettings = {};
|
let serverSettings = {};
|
||||||
try { serverSettings = await api('get_settings'); } catch(e) {}
|
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();
|
_updateGeminiButtonState();
|
||||||
const missing = _getMissingSetupSteps(serverSettings);
|
const missing = _getMissingSetupSteps(serverSettings);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
|
|||||||
+10
@@ -870,6 +870,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Security Tab -->
|
<!-- Security Tab -->
|
||||||
<div class="settings-panel" id="tab-security">
|
<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">
|
<div class="settings-card">
|
||||||
<h4>🔒 Certificato HTTPS</h4>
|
<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>
|
<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