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

@@ -553,6 +553,7 @@ class StarlarkAppsPlugin(BasePlugin):
Save apps manifest to file with file locking to prevent race conditions.
Uses exclusive lock during write to prevent concurrent modifications.
"""
temp_file = None
try:
# Use atomic write pattern: write to temp file, then rename
temp_file = self.manifest_file.with_suffix('.tmp')
@@ -573,10 +574,10 @@ class StarlarkAppsPlugin(BasePlugin):
except Exception as e:
self.logger.error(f"Error saving manifest: {e}")
# Clean up temp file if it exists
if temp_file.exists():
if temp_file and temp_file.exists():
try:
temp_file.unlink()
except:
except Exception:
pass
return False
@@ -879,7 +880,9 @@ class StarlarkAppsPlugin(BasePlugin):
def update_fn(manifest):
manifest["apps"][safe_app_id] = app_manifest
self._update_manifest_safe(update_fn)
if not self._update_manifest_safe(update_fn):
self.logger.error(f"Failed to update manifest for {app_id}")
return False
# Create app instance (use safe_app_id for internal key, original for display)
app = StarlarkApp(safe_app_id, app_dir, app_manifest)
@@ -913,19 +916,24 @@ class StarlarkAppsPlugin(BasePlugin):
if self.current_app and self.current_app.app_id == app_id:
self.current_app = None
# Remove from apps dict
app = self.apps.pop(app_id)
# Get app reference before removing from dict
app = self.apps.get(app_id)
# Remove directory
if app.app_dir.exists():
shutil.rmtree(app.app_dir)
# Update manifest
# Update manifest FIRST (before modifying filesystem)
def update_fn(manifest):
if app_id in manifest["apps"]:
del manifest["apps"][app_id]
self._update_manifest_safe(update_fn)
if not self._update_manifest_safe(update_fn):
self.logger.error(f"Failed to update manifest when uninstalling {app_id}")
return False
# Remove from apps dict
self.apps.pop(app_id)
# Remove directory (after manifest update succeeds)
if app and app.app_dir.exists():
shutil.rmtree(app.app_dir)
self.logger.info(f"Uninstalled Starlark app: {app_id}")
return True

View File

@@ -264,10 +264,11 @@ class PixletRenderer:
else:
value_str = str(value)
# Validate value doesn't contain shell metacharacters
# Allow alphanumeric, spaces, and common safe chars: .-_:/@#,
if not re.match(r'^[a-zA-Z0-9 .\-_:/@#,{}"\[\]]*$', value_str):
logger.warning(f"Skipping config value with unsafe characters for key {key}: {value_str}")
# Validate value doesn't contain dangerous shell metacharacters
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
# Allow: most printable chars including spaces, quotes, brackets, braces
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
continue
# Add as positional argument (not -c flag)
@@ -469,7 +470,7 @@ class PixletRenderer:
def _extract_get_schema_body(self, content: str) -> Optional[str]:
"""
Extract get_schema() function body.
Extract get_schema() function body using indentation-aware parsing.
Args:
content: .star file content
@@ -477,12 +478,45 @@ class PixletRenderer:
Returns:
Function body text, or None if not found
"""
# Find def get_schema():
pattern = r'def\s+get_schema\s*\(\s*\)\s*:(.*?)(?=\ndef\s|\Z)'
match = re.search(pattern, content, re.DOTALL)
# Find def get_schema(): line
pattern = r'^(\s*)def\s+get_schema\s*\(\s*\)\s*:'
match = re.search(pattern, content, re.MULTILINE)
if match:
return match.group(1)
if not match:
return None
# Get the indentation level of the function definition
func_indent = len(match.group(1))
func_start = match.end()
# Split content into lines starting after the function definition
lines_after = content[func_start:].split('\n')
body_lines = []
for line in lines_after:
# Skip empty lines
if not line.strip():
body_lines.append(line)
continue
# Calculate indentation of current line
stripped = line.lstrip()
line_indent = len(line) - len(stripped)
# If line has same or less indentation than function def, check if it's a top-level def
if line_indent <= func_indent:
# This is a line at the same or outer level - check if it's a function
if re.match(r'def\s+\w+', stripped):
# Found next top-level function, stop here
break
# Otherwise it might be a comment or other top-level code, stop anyway
break
# Line is indented more than function def, so it's part of the body
body_lines.append(line)
if body_lines:
return '\n'.join(body_lines)
return None
def _parse_schema_field(self, field_type: str, params_text: str, var_table: Dict) -> Optional[Dict[str, Any]]:
@@ -545,15 +579,24 @@ class PixletRenderer:
field_dict['icon'] = icon_match.group(1)
# default (can be string, bool, or variable reference)
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
# First try to match quoted strings (which may contain commas)
default_match = re.search(r'default\s*=\s*"([^"]*)"', params_text)
if not default_match:
# Try single quotes
default_match = re.search(r"default\s*=\s*'([^']*)'", params_text)
if not default_match:
# Fall back to unquoted value (stop at comma or closing paren)
default_match = re.search(r'default\s*=\s*([^,\)]+)', params_text)
if default_match:
default_value = default_match.group(1).strip()
# Handle boolean
if default_value in ('True', 'False'):
field_dict['default'] = default_value.lower()
# Handle string literal
elif default_value.startswith('"') and default_value.endswith('"'):
field_dict['default'] = default_value.strip('"')
# Handle string literal from first two patterns (already extracted without quotes)
elif re.search(r'default\s*=\s*["\']', params_text):
# This was a quoted string, use the captured content directly
field_dict['default'] = default_value
# Handle variable reference (can't resolve, use as-is)
else:
# Try to extract just the value if it's like options[0].value

View File

@@ -1,3 +1,3 @@
Pillow>=10.0.0
PyYAML>=6.0
requests>=2.31.0
Pillow>=10.4.0
PyYAML>=6.0.2
requests>=2.32.0

View File

@@ -462,6 +462,12 @@ class TronbyteRepository:
for file_item in dir_data:
if file_item.get('type') == 'file':
file_name = file_item.get('name')
# Ensure file_name is a non-empty string before validation
if not file_name or not isinstance(file_name, str):
logger.warning(f"Skipping file with invalid name in {dir_name}: {file_item}")
continue
# Validate filename for path traversal
if '..' in file_name or '/' in file_name or '\\' in file_name:
logger.warning(f"Skipping potentially unsafe file: {file_name}")