mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 21:33:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user