fix(plugins): prevent root-owned files from blocking plugin updates - #242
Conversation
…ions The operation history UI was reading from the wrong data source (operation_queue instead of operation_history), install/update records lacked version details, toggle operations used a type name that didn't match UI filters, and the Clear History button was non-functional. - Switch GET /plugins/operation/history to read from OperationHistory audit log with return type hint and targeted exception handling - Add DELETE /plugins/operation/history endpoint; wire up Clear button - Add _get_plugin_version helper with specific exception handling (FileNotFoundError, PermissionError, json.JSONDecodeError) and structured logging with plugin_id/path context - Record plugin version, branch, and commit details on install/update - Record install failures in the direct (non-queue) code path - Replace "toggle" operation type with "enable"/"disable" - Add normalizeStatus() in JS to map completed→success, error→failed so status filter works regardless of server-side convention - Truncate commit SHAs to 7 chars in details display - Fix HTML filter options, operation type colors, duplicate JS init Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The root ledmatrix service creates __pycache__ and data cache files owned by root inside plugin directories. The web service (non-root) cannot delete these when updating or uninstalling plugins, causing operations to fail with "Permission denied". Defense in depth with three layers: - Prevent: PYTHONDONTWRITEBYTECODE=1 in systemd service + run.py - Fallback: sudoers rules for rm on plugin directories - Code: _safe_remove_directory() now uses sudo as last resort, and all bare shutil.rmtree() calls routed through it Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughPrevents Python bytecode generation; centralizes safe plugin-directory removal with a three-stage fallback (normal → chmod-retry → sudo helper) and adds a vetted safe removal script plus sudoers exposure; adds permission-utils sudo removal helper; adds OperationHistory.clear_history and records version/branch/commit metadata for plugin install/update operations. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant API as Web API
participant StoreMgr as PluginStoreManager
participant PermUtils as permission_utils
participant FS as Filesystem
User->>API: Request install/update plugin
API->>StoreMgr: install_plugin()/update_plugin()
StoreMgr->>StoreMgr: _safe_remove_directory(target)
Note over StoreMgr,FS: Stage 1 — Normal removal
StoreMgr->>FS: shutil.rmtree(target)
alt Removed
FS-->>StoreMgr: Success
else Permission error
Note over StoreMgr,FS: Stage 2 — Repair perms & retry
StoreMgr->>FS: chmod -R (repair) and retry rmtree
alt Removed
FS-->>StoreMgr: Success
else Still denied
Note over StoreMgr,PermUtils: Stage 3 — Use sudo helper
StoreMgr->>PermUtils: sudo_remove_directory(target)
PermUtils->>FS: sudo safe_plugin_rm.sh -> rm -rf target
FS-->>PermUtils: Success/Failure
PermUtils-->>StoreMgr: True/False
end
end
StoreMgr-->>API: Removal result
API->>API: Record operation_history (version/branch/commit)
API-->>User: Operation result
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@scripts/install/configure_web_sudo.sh`:
- Around line 73-78: The sudoers lines granting $WEB_USER NOPASSWD access to
"$RM_PATH -rf $PROJECT_ROOT/plugin-repos/*" and similar are vulnerable to path
traversal via wildcards; replace the wildcard approach by either (A) removing
the wildcard and enumerating exact allowed targets passed from the application,
or (B) create a vetted helper such as safe_plugin_rm.sh that is granted sudo
instead of rm/find; the helper must realpath-resolve the provided target(s) and
verify they are inside $PROJECT_ROOT/plugin-repos or $PROJECT_ROOT/plugins
before calling rm -rf, and update the sudoers entries to allow $WEB_USER to run
only that helper (referenced symbols: $WEB_USER, $RM_PATH, $FIND_PATH,
$PROJECT_ROOT, safe_plugin_rm.sh).
In `@src/common/permission_utils.py`:
- Around line 203-238: The sudo_remove_directory function currently runs a
privileged 'sudo rm -rf' on any Path; add a defensive allow-list check before
invoking subprocess to ensure the resolved target is inside expected base
directories (e.g., the shared constants or the same bases used by
configure_web_sudo.sh such as "plugins" and "plugin-repos") or accept an
explicit allowed_bases parameter; implement validation by resolving the
candidate path (follow symlinks) and confirming it is a descendant of at least
one allowed base (use Path.resolve() and Path.is_relative_to() or an equivalent
check), and if the check fails log an error and return False without calling
subprocess.run; keep the rest of sudo_remove_directory behavior unchanged
(timeouts, logging, error handling).
In `@src/plugin_system/store_manager.py`:
- Around line 838-841: The code currently calls shutil.move(str(plugin_path),
str(correct_path)) after attempting to remove an existing directory without
checking _safe_remove_directory(correct_path)'s return value; update the logic
in the block handling correct_path and manifest_plugin_id so that you check the
boolean result of self._safe_remove_directory(correct_path) and if it returns
False, log an error/warning and return False (or otherwise abort) before calling
shutil.move to avoid proceeding on failed cleanup or nesting directories; ensure
you reference correct_path, manifest_plugin_id, _safe_remove_directory,
plugin_path and shutil.move when applying the change.
🧹 Nitpick comments (6)
scripts/install/configure_web_sudo.sh (1)
33-34:whichis not POSIX-portable; prefercommand -v.On some minimal systems (e.g., certain Raspberry Pi images),
whichmay not be installed or may behave differently.command -vis a shell built-in and universally available.Suggested diff
-RM_PATH=$(which rm)-FIND_PATH=$(which find)+RM_PATH=$(command -v rm)+FIND_PATH=$(command -v find)(Same applies to the existing
whichcalls on lines 27–32, but those are outside the scope of this change.)web_interface/templates/v3/partials/operation_history.html (1)
318-331: Clear history implementation is solid.The DELETE call, success/error handling, and local state cleanup are well-structured. The confirmation dialog prevents accidental purges.
One minor point: consider resetting
currentPage = 1before callingapplyFilters()on line 325 to avoid showing an empty page if the user was on a later page when clearing.Suggested tweak
if (data.status === 'success') { allHistory = []; + currentPage = 1; applyFilters();web_interface/blueprints/api_v3.py (2)
57-72: Add a fail-fast guard for missing plugin_id or managerIf this helper is called before the blueprint is fully initialized or with an empty plugin_id, it can raise AttributeError or read from the plugins root. A quick guard keeps behavior deterministic and avoids noisy warnings.
Suggested guard
def _get_plugin_version(plugin_id: str) -> str: """Read the installed version from a plugin's manifest.json. @@ """ + if not plugin_id or not getattr(api_v3, "plugin_store_manager", None):+ return '' manifest_path = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id / "manifest.json"As per coding guidelines "Validate inputs and handle errors early (Fail Fast principle)".
2162-2165: Avoid mislabeling toggle failures whenenabledisn't resolvedIf the exception happens before
datais populated, the fallback always logs “disable.” Consider preferringenabledwhen available, else default to “toggle.”Optional tweak
- if api_v3.operation_history:- toggle_type = "enable" if ('data' in locals() and data.get('enabled')) else "disable"+ if api_v3.operation_history:+ toggle_type = "toggle"+ if 'enabled' in locals():+ toggle_type = "enable" if enabled else "disable"+ elif 'data' in locals() and 'enabled' in data:+ toggle_type = "enable" if bool(data.get('enabled')) else "disable"src/plugin_system/store_manager.py (2)
1567-1625: Well-structured three-stage removal — a few minor improvements.The defense-in-depth approach (normal → chmod → sudo) is sound and well-documented. A few observations:
dirsloop variable unused (Line 1598): Rename to_dirsto signal intent.import statinside method (Line 1597): Move to the module-level imports for clarity.- Stage 2 chmod 0o777 won't help for root-owned files (Lines 1601, 1606): You can't
chmodfiles you don't own, so this stage only works for same-user files as documented. Consider usingstat.S_IRWXU(0o700) instead — it's sufficient for deletion by the owning user and avoids the S103 lint warning about overly permissive masks.♻️ Suggested refinements
+import stat # at module-level imports def _safe_remove_directory(self, path: Path) -> bool: ... # Stage 2: Try chmod + retry (works when we own the files) try: - import stat- for root, dirs, files in os.walk(path):+ for root, _dirs, files in os.walk(path): root_path = Path(root) try: - os.chmod(root_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)+ os.chmod(root_path, stat.S_IRWXU) except (OSError, PermissionError): pass for file in files: try: - os.chmod(root_path / file, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)+ os.chmod(root_path / file, stat.S_IRWXU) except (OSError, PermissionError): pass
1053-1056: Remainingshutil.rmtree(temp_dir)infinallyblock could mask exceptions.This temp directory cleanup wasn't converted to
_safe_remove_directory. Since temp directories are user-owned, permission issues are unlikely, but a bareshutil.rmtreewithoutignore_errors=Truecould raise and mask the original exception from thetryblock. Consider addingignore_errors=Truefor consistency with similar cleanup at Lines 1377 and 1432.♻️ Suggested fix
finally: # Cleanup temp directory if it still exists if temp_dir and temp_dir.exists(): - shutil.rmtree(temp_dir)+ shutil.rmtree(temp_dir, ignore_errors=True)
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Address code review findings: - Replace raw rm/find sudoers wildcards with a vetted helper script (safe_plugin_rm.sh) that resolves symlinks and validates the target is a strict child of plugin-repos/ or plugins/ before deletion - Add allow-list validation in sudo_remove_directory() that checks resolved paths against allowed bases before invoking sudo - Check _safe_remove_directory() return value before shutil.move() in the manifest ID rename path - Move stat import to module level in store_manager.py - Use stat.S_IRWXU instead of 0o777 in chmod fallback stage - Add ignore_errors=True to temp dir cleanup in finally block - Use command -v instead of which in configure_web_sudo.sh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@scripts/fix_perms/safe_plugin_rm.sh`:
- Around line 25-28: The ALLOWED_BASES initialization in safe_plugin_rm.sh uses
realpath which fails if directories don't exist; change the calls inside
ALLOWED_BASES to use realpath --canonicalize-missing on
"$PROJECT_ROOT/plugin-repos" and "$PROJECT_ROOT/plugins" (preserving existing
quoting) so missing directories won't abort the script under set -e; update the
ALLOWED_BASES assignment that references realpath to use the
--canonicalize-missing flag for both entries.
In `@src/common/permission_utils.py`:
- Around line 263-269: The subprocess.run invocation hardcodes '/bin/bash',
which can mismatch the sudoers BASH_PATH; import shutil at top and resolve the
real bash path (e.g., shutil.which('bash') with a safe fallback) and use that
resolved path in the subprocess.run call that currently passes ['/bin/bash',
str(helper_script), str(path)] (refer to the subprocess.run usage and the
helper_script/path variables) so the invoked shell path exactly matches the
sudoers entry.
In `@src/plugin_system/store_manager.py`:
- Around line 1366-1369: The call to self._safe_remove_directory(target_path)
must be checked before proceeding to shutil.move to avoid moving into a
partially-removed target; modify the block that currently calls
_safe_remove_directory then shutil.move so that after target_path.exists() you
call removed = self._safe_remove_directory(target_path) and if removed is falsy
(or an exception occurs) abort/raise a clear error (including target_path and
source_plugin_dir) or return early instead of calling shutil.move; ensure
shutil.move is only executed when _safe_remove_directory reports success.
- Around line 1588-1596: The removal logic around shutil.rmtree currently only
catches PermissionError and treats other exceptions as fatal, which skips the
chmod retry stages; update the try/except in the method that calls shutil.rmtree
(store_manager.py, the function performing "Stage 1: Try normal removal") to
catch OSError instead of PermissionError for permission-related failures so
EPERM (errno 1) is handled the same as EACCES, and keep the existing broad
Exception handler for truly unexpected exceptions (or re-raise/log non-OSError
exceptions) so stages 2 and 3 still run on permission failures; reference the
shutil.rmtree call and the existing except blocks to locate and change the catch
to OSError.
🧹 Nitpick comments (3)
scripts/fix_perms/safe_plugin_rm.sh (1)
59-60: Add--before the path argument torm -rf.While the path validation above makes it unlikely for the target to start with a dash, using
--is a defensive best practice for commands invoked via sudo.Proposed fix
# All checks passed — remove the target -rm -rf "$RESOLVED_TARGET"+rm -rf -- "$RESOLVED_TARGET"src/plugin_system/store_manager.py (1)
1617-1618: Consider hoisting the import to module level.The lazy import of
sudo_remove_directoryinside_safe_remove_directoryis evaluated on every sudo-fallback invocation. Sincepermission_utilsis already part of the project (and imported elsewhere in this file at Lines 1263, 1361, 1423), moving it to the top-level imports would be cleaner.Proposed change
At the top of the file (around line 19), add:
fromsrc.common.permission_utilsimportsudo_remove_directoryThen simplify Line 1618:
- from src.common.permission_utils import sudo_remove_directory- if sudo_remove_directory(path):+ if sudo_remove_directory(path):scripts/install/configure_web_sudo.sh (1)
71-74: Sudoers entry correctly delegates to the vetted helper script.The previous wildcard vulnerability (past review) is properly addressed by routing through
safe_plugin_rm.sh, which performs its ownrealpathvalidation. The*wildcard in sudoers is now safe because the helper rejects invalid paths and requires exactly one argument.One minor consideration: the script at
SAFE_RM_PATHmust be owned by root and not writable by the web user, otherwise privilege escalation is possible. You may want to add achmod 755/chown root:rootstep for the helper script when applying the sudoers config, or document this requirement.#!/bin/bash# Check if there's any ownership/permission hardening for safe_plugin_rm.sh# in the install scripts or documentation rg -n "safe_plugin_rm" -g '*.sh' -g '*.md' -g '*.txt'
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…andling
- safe_plugin_rm.sh: use realpath --canonicalize-missing for ALLOWED_BASES
so the script doesn't fail under set -e when dirs don't exist yet
- safe_plugin_rm.sh: add -- before path in rm -rf to prevent flag injection
- permission_utils.py: use shutil.which('bash') instead of hardcoded /bin/bash
to match whatever path the sudoers BASH_PATH resolves to
- store_manager.py: check _safe_remove_directory() return before shutil.move()
in _install_from_monorepo_zip to prevent moving into a non-removed target
- store_manager.py: catch OSError instead of PermissionError in Stage 1 removal
to handle both EACCES and EPERM error codes
- store_manager.py: hoist sudo_remove_directory import to module level
- configure_web_sudo.sh: harden safe_plugin_rm.sh to root-owned 755 so
the web user cannot modify the vetted helper script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@scripts/install/configure_web_sudo.sh`:
- Around line 27-33: Validate the results of each command -v call in
configure_web_sudo.sh by checking PYTHON_PATH, SYSTEMCTL_PATH, REBOOT_PATH,
POWEROFF_PATH, BASH_PATH, JOURNALCTL_PATH and SAFE_RM_PATH for non-empty values
before using them to generate sudoers entries; if any are empty either (a) abort
with a clear error and non-zero exit (so the install fails fast) or (b) omit
only the related sudoers lines and log a warning, and ensure you run visudo -c
(or use sudo cp to /etc/sudoers.d/ with validation) only after validation
succeeds so malformed entries like "NOPASSWD: start ledmatrix.service" are
never written.
🧹 Nitpick comments (2)
src/common/permission_utils.py (2)
266-272: Passes unresolvedpathto subprocess after validatingresolved— minor inconsistency.Line 268 passes the original
pathto the helper script, while the validation on Lines 240-256 operates on theresolvedpath. The helper does its ownrealpathresolution so the end result is the same, but passing the already-resolved path would be more consistent and eliminate a (theoretical) TOCTOU window between the two resolutions.Suggested change
result = subprocess.run( - ['sudo', '-n', bash_path, str(helper_script), str(path)],+ ['sudo', '-n', bash_path, str(helper_script), str(resolved)], capture_output=True, text=True, timeout=30 )
273-275:path.exists()check after removal uses the original (possibly symlink) path.If
pathwas a symlink,rm -rfon the resolved target won't remove the dangling symlink itself, sopath.exists()could returnFalse(dangling symlink) orTruedepending on OS behavior. Usingresolved(orpath.resolve()) for the existence check would be more reliable.
Uh oh!
There was an error while loading. Please reload this page.
…ved paths - configure_web_sudo.sh: validate that required commands (systemctl, bash, python3) resolve to non-empty paths before generating sudoers entries; abort with clear error if any are missing; skip optional commands (reboot, poweroff, journalctl) with a warning instead of emitting malformed NOPASSWD lines; validate helper script exists on disk - permission_utils.py: pass the already-resolved path to the subprocess call and use it for the post-removal exists() check, eliminating a TOCTOU window between Python-side validation and shell-side execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Switch from resolve()+relative_to() to os.path.basename() reassignment, which CodeQL recognizes as a path sanitizer that breaks the taint chain. Also remove exception objects from backup_manager validate_backup return strings to eliminate the stack-trace-exposure taint source. Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241, #242, #243, #244, #245, #246, #247. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ss install handler warning (#346) * fix(web-ui): fix quick actions not firing, add toast feedback, suppress install handler warning - base.html: add htmx:afterSettle listener to set data-loaded on tab containers after HTMX swaps their content, preventing the overview partial from being re-fetched (and handlers lost) on every tab switch - base.html: call htmx.process() in loadOverviewDirect/loadPluginsDirect fallbacks so buttons get HTMX handlers even if HTMX finished its initial body scan before the fallback fetch completed - overview.html + index.html (11 buttons): replace event.detail.xhr.responseJSON (undefined in HTMX 1.9.x) with JSON.parse(event.detail.xhr.responseText) so quick action toast notifications actually fire - plugins_manager.js: add guarded htmx:afterSettle listener that only calls attachInstallButtonHandler when #install-plugin-from-url is in the DOM, eliminating the spurious console warning on non-plugin tab loads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): ensure quick-action toasts always fire even on xhr/parse failure Replace silent catch(e){} in all 11 hx-on:htmx:after-request handlers with a pattern that sets default message/status before the try block and calls showNotification(m,s) unconditionally after it, so a fallback toast is shown whenever xhr is absent or responseText is not valid JSON. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): show error toast on non-JSON 4xx/5xx quick-action responses In the catch block of all 11 hx-on:htmx:after-request handlers, check xhr.status >= 400 and downgrade s to 'error' so a failed action that returns an HTML error page (or other non-JSON body) surfaces as an error toast instead of the optimistic 'success'/'info' default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web-ui): guard setTimeout fallback for attachInstallButtonHandler The 500ms fallback setTimeout was calling attachInstallButtonHandler() unconditionally even when the plugins partial wasn't in the DOM, causing a spurious console.warn on every page load. Add the same element-existence check already present on the htmx:afterSettle listener. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix backup API 404s, hardware status 500, and HTMX loading race - Add all backup API routes to api_v3.py: preview, list, export, validate, restore (with plugin reinstall), download, delete - Fix PermissionError on /hardware/status: return graceful 200 instead of 500 when the status file is owned by a different user; also fix root cause by writing the file world-readable (0o644) in display_manager - Fix HTMX race: dispatch htmx:ready window event from HTMX onload callback; loadTabContent now waits for that event instead of immediately falling back to direct fetch (eliminating the "HTMX not available" console warning on initial load) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Cancel HTMX fallback timers when htmx:ready fires The 5-second setTimeout fallbacks for plugins and overview were firing before the htmx:ready event arrived, logging spurious warnings. Each timer now self-cancels via htmx:ready so the fallback only triggers when HTMX genuinely fails to load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address review feedback: error leaks, ok:false, htmx:ready coverage - Backup endpoints: replace raw str(e) in user-facing responses with a generic message; full exception still logged via exc_info=True - hardware/status: change ok:null to ok:false for PermissionError and json.JSONDecodeError so the UI's hw.ok===false check triggers correctly - base.html: dispatch htmx:ready from the fallback load path so any deferred listeners fire on CDN-fallback loads too - loadTabContent: also listen for htmx-load-failed so overview/wifi/plugins fall back to direct fetch when HTMX is completely unavailable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Treat system-managed pip packages as satisfied for dependency marker When a plugin's requirements.txt includes a package installed via the system package manager (dnf/apt), pip fails with 'uninstall-no-record-file' because it can't replace the system-tracked copy. The package is present and functional, but the missing marker caused the install to be retried on every service restart. Detect this specific error pattern: if the only pip failure is uninstall-no-record-file, write the .dependencies_installed marker and log a warning instead of returning False, suppressing the repeated warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix uninstall-no-record-file detection condition The previous check used a string replacement that left 'error:' in the remaining text, causing the condition to always evaluate false. Simplify to a direct substring check: if 'uninstall-no-record-file' appears in pip stderr the affected package is installed at the system level and we write the marker, suppressing the repeated warning on every restart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve CodeQL security findings in backup API Path traversal (CWE-22): - backup_download: switch from send_file(user-tainted-path) to send_from_directory(_BACKUP_EXPORT_DIR, filename); Flask uses werkzeug safe_join internally which CodeQL recognises as a sanitizer - backup_delete: enumerate the export directory and match by name so entry.unlink() operates on a filesystem-derived Path rather than one constructed from user input; _safe_backup_path still guards first Information exposure through exceptions (CWE-209): - backup_validate: err_msg from validate_backup() can embed exception strings containing temp-file paths; log the detail, return a generic 'Invalid or corrupted backup file' to the client - Other backup endpoints: already fixed (str(e) -> generic message); CodeQL alerts will clear on next scan plugin_loader.py:185 (path traversal): false positive — requirements_file is constructed from plugin_dir returned by find_plugin_directory() (a filesystem scan), not from raw HTTP request input; no change needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pre-existing information exposure in version and action endpoints - get_system_version (alert #218): replaced str(e) with generic message; exception still logged via logger.error(exc_info=True) - execute_system_action (alert #216): removed str(e) and full traceback.format_exc() from the HTTP response — the full stack trace was being sent directly to clients; replaced with generic message and proper logger.error call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix remaining GitHub CodeQL security alerts - py/stack-trace-exposure: Remove str(e) and traceback.format_exc() from all HTTP responses across api_v3.py, pages_v3.py, and app.py; replace with generic messages and logger.error(exc_info=True) - py/reflective-xss: Escape partial_name via markupsafe.escape in the load_partial 404 response - py/path-injection: Add regex validation of plugin_id before filesystem use in _load_plugin_config_partial - py/incomplete-url-substring-sanitization: Replace 'github.com' in substring checks with urlparse hostname comparison in store_manager.py - py/clear-text-logging-sensitive-data: Remove football-scoreboard debug prints and sensitive request-body prints from update endpoint - js/bad-tag-filter: Replace script-only regex in BaseWidget.sanitizeValue with DOM-based textContent stripping that removes all HTML - js/incomplete-sanitization: Fix escapeAttr to properly encode &, ", ', <, > using HTML entities instead of backslash escaping - js/prototype-pollution-utility: Add __proto__/constructor/prototype key guards to deepMerge function in plugins_manager.js - app.py error handlers: Always return generic messages; remove debug-mode branches that could expose tracebacks in production Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix three remaining CodeQL path-injection and info-exposure alerts - plugin_loader.py: resolve plugin_dir with strict=True and validate marker_path with relative_to() before any filesystem writes, giving CodeQL the positive sanitization pattern it requires (py/path-injection) - api_v3.py _safe_backup_path: replace substring negative checks with a strict positive regex (^[a-zA-Z0-9][a-zA-Z0-9._-]{0,200}\.zip$) that CodeQL recognises as sanitising the user-supplied filename (py/path-injection) - api_v3.py backup_validate: whitelist known-safe manifest fields before returning JSON, preventing any exception strings captured inside validate_backup() from reaching the HTTP response (py/stack-trace-exposure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Resolve 29 open CodeQL security alerts across 5 files py/flask-debug (#214): - debug_web_manual.py: read debug mode from LEDMATRIX_FLASK_DEBUG env var instead of hardcoded True py/stack-trace-exposure (#216, #218): - api_v3.py execute_system_action: remove subprocess stdout/stderr from HTTP responses; log via logger instead - api_v3.py get_git_version: validate output matches safe ref format (^[a-zA-Z0-9._-]+$) before including in response - api_v3.py: remove all remaining traceback.format_exc() dead variables and print() debug calls (replaced with logger.debug/warning) py/reflective-xss (#207, #208, #209, #210, #211, #212): - api_v3.py: remove plugin_id from all error/success response messages (uninstall, install, update, health, not-found responses) - pages_v3.py load_partial: return static "Partial not found" message instead of echoing partial_name - pages_v3.py _load_starlark_config_partial: add app_id regex validation, use static error messages instead of f-strings with app_id py/path-injection (#187–#206): - pages_v3.py _load_plugin_config_partial: resolve plugins_base and validate _plugin_dir with relative_to() before all file operations; same for assets metadata directory - pages_v3.py _load_starlark_config_partial: resolve starlark_base and validate schema_file/config_file paths with relative_to() - plugin_loader.py _find_plugin_directory: resolve plugins_dir and validate strategy-2 candidates with relative_to() - plugin_loader.py install_dependencies: resolve plugin_dir first, then construct requirements_file and marker_path from resolved base - plugin_loader.py load_module: resolve plugin_dir with strict=True and validate entry_file with relative_to() before exec_module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix 15 remaining CodeQL path-injection and stack-trace-exposure alerts Switch from resolve()+relative_to() to os.path.basename() reassignment, which CodeQL recognizes as a path sanitizer that breaks the taint chain. Also remove exception objects from backup_manager validate_backup return strings to eliminate the stack-trace-exposure taint source. Fixes alerts #227, #233, #234, #235, #237, #238, #239, #240, #241, #242, #243, #244, #245, #246, #247. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix broken logger format string and leaked exception in config save error - pages_v3.py: plain string was used instead of %-style substitution, so every manifest-read failure logged the literal "{plugin_id}" - api_v3.py save_main_config: exception message was still leaking through the error response; replace with generic message (consistent with the rest of the CodeQL sweep in this PR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
ledmatrix.servicecreates__pycache__and data cache files owned by root inside plugin directoriesApproach: Defense in Depth (3 Layers)
Layer 1: Prevention
Environment=PYTHONDONTWRITEBYTECODE=1toledmatrix.servicesystemd templatesys.dont_write_bytecode = Trueinrun.pyas belt-and-suspendersLayer 2: Sudoers fallback
rm -rfsudoers rules forplugin-repos/*andplugins/*directories inconfigure_web_sudo.shLayer 3: Code-level sudo fallback
sudo_remove_directory()utility inpermission_utils.pyusingsudo -n(non-interactive)_safe_remove_directory()with 3-stage approach: normal rmtree → chmod fix → sudo fallbackshutil.rmtree()calls with_safe_remove_directory()throughoutstore_manager.pyTest plan
sudo bash scripts/install/install_service.sh)configure_web_sudo.shon devpi__pycache__dirs are created🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
UI Updates