fix(backup): address second round of PR review findings

- api_v3: guard opts_dict with isinstance check after json.loads so a
  non-object JSON payload (null, array, etc.) returns a 400 instead of a
  500 AttributeError
- backup_manager: wrap tmp ZIP creation and os.replace in try/except so
  the .zip.tmp temp file is always removed on any failure
- backup_manager: replace hardcoded Path("/tmp/_zip_check") sentinel in
  validate_backup with a proper tempfile.TemporaryDirectory() so path
  traversal checks are portable and leave no artifacts
- backup_restore.html: detect partial-success responses (plugins_failed or
  errors non-empty) even when status is 'success' and render yellow/warning
  styling and notify instead of green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-04-27 12:03:47 -04:00
parent b609b9e9e1
commit 3d44e15a0d
3 changed files with 60 additions and 49 deletions

View File

@@ -291,48 +291,52 @@ def create_backup(
# Stream directly to a temp file so we never hold the whole ZIP in memory. # Stream directly to a temp file so we never hold the whole ZIP in memory.
tmp_path = zip_path.with_suffix(".zip.tmp") tmp_path = zip_path.with_suffix(".zip.tmp")
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: try:
# Config files. with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
if (project_root / _CONFIG_REL).exists(): # Config files.
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix()) if (project_root / _CONFIG_REL).exists():
contents.append("config") zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
if (project_root / _SECRETS_REL).exists(): contents.append("config")
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix()) if (project_root / _SECRETS_REL).exists():
contents.append("secrets") zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
if (project_root / _WIFI_REL).exists(): contents.append("secrets")
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix()) if (project_root / _WIFI_REL).exists():
contents.append("wifi") zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
contents.append("wifi")
# User-uploaded fonts. # User-uploaded fonts.
user_fonts = iter_user_fonts(project_root) user_fonts = iter_user_fonts(project_root)
if user_fonts: if user_fonts:
for font in user_fonts: for font in user_fonts:
arcname = font.relative_to(project_root).as_posix() arcname = font.relative_to(project_root).as_posix()
zf.write(font, arcname) zf.write(font, arcname)
contents.append("fonts") contents.append("fonts")
# Plugin uploads. # Plugin uploads.
plugin_uploads = iter_plugin_uploads(project_root) plugin_uploads = iter_plugin_uploads(project_root)
if plugin_uploads: if plugin_uploads:
for upload in plugin_uploads: for upload in plugin_uploads:
arcname = upload.relative_to(project_root).as_posix() arcname = upload.relative_to(project_root).as_posix()
zf.write(upload, arcname) zf.write(upload, arcname)
contents.append("plugin_uploads") contents.append("plugin_uploads")
# Installed plugins manifest. # Installed plugins manifest.
plugins = list_installed_plugins(project_root) plugins = list_installed_plugins(project_root)
if plugins: if plugins:
zf.writestr( zf.writestr(
PLUGINS_MANIFEST_NAME, PLUGINS_MANIFEST_NAME,
json.dumps(plugins, indent=2), json.dumps(plugins, indent=2),
) )
contents.append("plugins") contents.append("plugins")
# Manifest goes last so that `contents` reflects what we actually wrote. # Manifest goes last so that `contents` reflects what we actually wrote.
manifest = _build_manifest(contents, project_root) manifest = _build_manifest(contents, project_root)
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2)) zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
os.replace(tmp_path, zip_path) os.replace(tmp_path, zip_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size) logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
return zip_path return zip_path
@@ -391,15 +395,17 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
return False, "Backup is missing manifest.json", {} return False, "Backup is missing manifest.json", {}
total = 0 total = 0
for info in zf.infolist(): with tempfile.TemporaryDirectory() as _sandbox:
if info.file_size > _MAX_MEMBER_BYTES: sandbox = Path(_sandbox)
return False, f"Member {info.filename} is too large", {} for info in zf.infolist():
total += info.file_size if info.file_size > _MAX_MEMBER_BYTES:
if total > _MAX_TOTAL_BYTES: return False, f"Member {info.filename} is too large", {}
return False, "Backup exceeds maximum allowed size", {} total += info.file_size
# Safety: reject members with unsafe paths up front. if total > _MAX_TOTAL_BYTES:
if _safe_extract_path(Path("/tmp/_zip_check"), info.filename) is None: return False, "Backup exceeds maximum allowed size", {}
return False, f"Unsafe path in backup: {info.filename}", {} # Safety: reject members with unsafe paths up front.
if _safe_extract_path(sandbox, info.filename) is None:
return False, f"Unsafe path in backup: {info.filename}", {}
try: try:
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8") manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")

View File

@@ -1296,6 +1296,8 @@ def backup_restore():
opts_dict = json.loads(raw_opts) opts_dict = json.loads(raw_opts)
except json.JSONDecodeError: except json.JSONDecodeError:
return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400 return jsonify({'status': 'error', 'message': 'Invalid options JSON'}), 400
if not isinstance(opts_dict, dict):
return jsonify({'status': 'error', 'message': 'options must be an object'}), 400
opts = backup_manager.RestoreOptions( opts = backup_manager.RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)), restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),

View File

@@ -313,11 +313,14 @@
throw new Error(payload.message || msgs || 'Restore had errors'); throw new Error(payload.message || msgs || 'Restore had errors');
} }
const data = payload.data || {}; const data = payload.data || {};
const hasPartial = (data.plugins_failed || []).length > 0 || (data.errors || []).length > 0;
const result = document.getElementById('restore-result'); const result = document.getElementById('restore-result');
result.className = 'bg-green-50 border-green-200 text-green-800 border rounded-md p-4'; result.className = (hasPartial
? 'bg-yellow-50 border-yellow-200 text-yellow-800'
: 'bg-green-50 border-green-200 text-green-800') + ' border rounded-md p-4';
result.classList.remove('hidden'); result.classList.remove('hidden');
result.innerHTML = ` result.innerHTML = `
<h3 class="font-medium mb-2">Restore complete</h3> <h3 class="font-medium mb-2">${hasPartial ? 'Restore complete with warnings' : 'Restore complete'}</h3>
<div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div> <div><strong>Restored:</strong> ${(data.restored || []).map(escapeHtml).join(', ') || 'none'}</div>
<div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div> <div><strong>Skipped:</strong> ${(data.skipped || []).map(escapeHtml).join(', ') || 'none'}</div>
<div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div> <div><strong>Plugins installed:</strong> ${(data.plugins_installed || []).map(escapeHtml).join(', ') || 'none'}</div>
@@ -325,7 +328,7 @@
<div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div> <div><strong>Errors:</strong> ${(data.errors || []).map(escapeHtml).join('; ') || 'none'}</div>
<p class="mt-2">Restart the display service to apply all changes.</p> <p class="mt-2">Restart the display service to apply all changes.</p>
`; `;
notify('Restore complete', 'success'); notify(hasPartial ? 'Restore complete with warnings' : 'Restore complete', hasPartial ? 'warning' : 'success');
} catch (err) { } catch (err) {
notify('Restore failed: ' + err.message, 'error'); notify('Restore failed: ' + err.message, 'error');
} finally { } finally {