fix(starlark): code review fixes - security, robustness, and schema parsing

## Security Fixes
- manager.py: Check _update_manifest_safe return values to prevent silent failures
- manager.py: Improve temp file cleanup in _save_manifest to prevent leaks
- manager.py: Fix uninstall order (manifest → memory → disk) for consistency
- api_v3.py: Add path traversal validation in uninstall endpoint
- api_v3.py: Implement atomic writes for manifest files with temp + rename
- pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters

## Frontend Robustness
- plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing)
- starlark_config.html: Scope querySelector to container to prevent modal conflicts

## Schema Parsing Improvements
- pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions)
- pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY")
- tronbyte_repository.py: Validate file_name is string before path traversal checks

## Dependencies
- requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0)

## Documentation
- docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining:
  - How Starlark apps work
  - That apps come from Tronbyte (not LEDMatrix)
  - Installation, configuration, troubleshooting
  - Links to upstream projects

All changes improve security, reliability, and user experience.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-19 16:58:22 -05:00
parent 5d213b5747
commit 441b3c56e9
8 changed files with 667 additions and 43 deletions

View File

@@ -7078,14 +7078,29 @@ def _read_starlark_manifest() -> dict:
def _write_starlark_manifest(manifest: dict) -> bool:
"""Write the starlark-apps manifest.json to disk."""
"""Write the starlark-apps manifest.json to disk with atomic write."""
temp_file = None
try:
_STARLARK_APPS_DIR.mkdir(parents=True, exist_ok=True)
with open(_STARLARK_MANIFEST_FILE, 'w') as f:
# Atomic write pattern: write to temp file, then rename
temp_file = _STARLARK_MANIFEST_FILE.with_suffix('.tmp')
with open(temp_file, 'w') as f:
json.dump(manifest, f, indent=2)
f.flush()
os.fsync(f.fileno()) # Ensure data is written to disk
# Atomic rename (overwrites destination)
temp_file.replace(_STARLARK_MANIFEST_FILE)
return True
except OSError as e:
logger.error(f"Error writing starlark manifest: {e}")
# Clean up temp file if it exists
if temp_file and temp_file.exists():
try:
temp_file.unlink()
except Exception:
pass
return False
@@ -7398,7 +7413,15 @@ def uninstall_starlark_app(app_id):
else:
# Standalone: remove app dir and manifest entry
import shutil
app_dir = _STARLARK_APPS_DIR / app_id
app_dir = (_STARLARK_APPS_DIR / app_id).resolve()
# Path traversal check - ensure app_dir is within _STARLARK_APPS_DIR
try:
app_dir.relative_to(_STARLARK_APPS_DIR.resolve())
except ValueError:
logger.warning(f"Path traversal attempt in uninstall: {app_id}")
return jsonify({'status': 'error', 'message': 'Invalid app_id'}), 400
if app_dir.exists():
shutil.rmtree(app_dir)
manifest = _read_starlark_manifest()

View File

@@ -1,6 +1,43 @@
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
const safeLocalStorage = {
getItem(key) {
try {
if (typeof localStorage !== 'undefined') {
return safeLocalStorage.getItem(key);
}
} catch (e) {
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
}
return null;
},
setItem(key, value) {
try {
if (typeof localStorage !== 'undefined') {
safeLocalStorage.setItem(key, value);
return true;
}
} catch (e) {
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
}
return false;
},
removeItem(key) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
return true;
}
} catch (e) {
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
}
return false;
}
};
// Define critical functions immediately so they're available before any HTML is rendered
// Debug logging controlled by localStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
@@ -865,7 +902,7 @@ window.currentPluginConfig = null;
// Store filter/sort state
const storeFilterState = {
sort: localStorage.getItem('storeSort') || 'a-z',
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
filterVerified: false,
filterNew: false,
filterInstalled: null, // null = all, true = installed only, false = not installed only
@@ -873,7 +910,7 @@ window.currentPluginConfig = null;
filterCategories: [],
persist() {
localStorage.setItem('storeSort', this.sort);
safeLocalStorage.setItem('storeSort', this.sort);
},
reset() {
@@ -898,7 +935,7 @@ window.currentPluginConfig = null;
};
// Installed plugins sort state
let installedSort = localStorage.getItem('installedSort') || 'a-z';
let installedSort = safeLocalStorage.getItem('installedSort') || 'a-z';
// Shared on-demand status store (mirrors Alpine store when available)
window.__onDemandStore = window.__onDemandStore || {
@@ -1251,8 +1288,8 @@ const pluginLoadCache = {
}
};
// Debug flag - set via localStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
function pluginLog(...args) {
if (PLUGIN_DEBUG) console.log(...args);
}
@@ -5269,7 +5306,7 @@ function setupStoreFilterListeners() {
installedSortSelect.value = installedSort;
installedSortSelect.addEventListener('change', () => {
installedSort = installedSortSelect.value;
localStorage.setItem('installedSort', installedSort);
safeLocalStorage.setItem('installedSort', installedSort);
const plugins = window.installedPlugins || [];
if (plugins.length > 0) {
sortAndRenderInstalledPlugins(plugins);

View File

@@ -155,13 +155,13 @@
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}"
oninput="document.querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB"
data-starlark-config="{{ field_id }}"
oninput="var cp = document.querySelector('[data-starlark-color-picker={{ field_id }}]'); if(this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
oninput="var cp = this.closest('.space-y-6').querySelector('[data-starlark-color-picker={{ field_id }}]'); if(cp && this.value.match(/^#[0-9a-fA-F]{6}$/)) cp.value = this.value;">
</div>
{% if field_desc %}
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
@@ -375,8 +375,15 @@ function toggleStarlarkApp(appId, enabled) {
function saveStarlarkConfig(appId) {
var config = {};
// Get container to scope queries (prevents conflicts if multiple modals open)
var container = document.getElementById('plugin-config-starlark:' + appId);
if (!container) {
console.error('Container not found for appId:', appId);
return;
}
// Collect standard inputs (text, number, select, datetime, color text companion)
document.querySelectorAll('[data-starlark-config]').forEach(function(input) {
container.querySelectorAll('[data-starlark-config]').forEach(function(input) {
var key = input.getAttribute('data-starlark-config');
var type = input.getAttribute('data-starlark-type');
@@ -390,7 +397,7 @@ function saveStarlarkConfig(appId) {
});
// Collect location mini-form groups
document.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
container.querySelectorAll('[data-starlark-location-group]').forEach(function(group) {
var fieldId = group.getAttribute('data-starlark-location-group');
var loc = {};
group.querySelectorAll('[data-starlark-location-field="' + fieldId + '"]').forEach(function(sub) {