mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
fix(starlark): stop the root display service locking the web UI out of starlark-apps (#604)
* fix(starlark): stop the root display service locking the web UI out
Reported after a fresh install: installing an app from the Starlark tab
failed with "install failed: Failed to install from repository", and so did
uploading a .star file and installing from a GitHub directory. The reporter
found the cause only by reading service logs, and fixed it with
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps
starlark-apps is gitignored, so it is never checked out -- it is created
lazily by whichever process reaches it first. Those processes run as
different users. systemd/ledmatrix.service is User=root and constructs this
plugin at startup, which is where _get_apps_directory() is called from;
systemd/ledmatrix-web.service runs as the login user and is what actually
installs apps.
The documented first step is to install pixlet and reboot, so on a fresh
machine the display service usually wins that race and mkdir() leaves the
directory root-owned. The web process then fails in _install_star_file() on
app_dir.mkdir(), which catches nothing, so PermissionError reaches the
route's outer `except Exception` and becomes the generic message the user
saw. All three install paths write to the same directory, which is why all
three failed.
The web user cannot repair this -- chown needs root. So root does it, on
every startup, which also heals machines already broken by this without the
owner having to find the chown themselves. It is a no-op when not root, when
the platform has no POSIX ownership, and when the checkout genuinely belongs
to root; a chown that fails warns rather than killing startup.
Also made the failure legible if the handover is ever prevented: a
PermissionError now names the directory, the automatic repair, and the
manual chown, instead of a message that names neither path nor cause.
Verified by mutation: dropping the handover call, chowning a genuinely
root-owned checkout, and letting a non-root process chown each fail their
own test. 121 starlark tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
* fix(starlark): address the review on the ownership repair
Findings from the automated review of #604.
Symlinks (CWE-59, the serious one). A root chown that follows links is a
privilege-escalation primitive: anyone able to write in starlark-apps could
point a link at a root-owned file and have the repair hand it over. Entries
are now read with os.lstat, symlinks are skipped outright, and the chown
passes follow_symlinks=False. Descendants are processed before the directory
itself, so the container does not change hands while its contents are still
being walked.
install_app() caught PermissionError in its broad handler and returned
False, which both routes report as a generic install failure -- the exact
shape of the bug this PR exists to fix, since the caller could not tell
"this app is broken" from "this process cannot write here". PermissionError
is now re-raised; every other failure still returns False.
The test fixtures skipped on bare Exception, which would have turned a
syntax error or NameError in the plugin into a green run. They now skip only
for a named absent dependency and re-raise anything else.
Also fixed the _Stat stub that failed in CI but passed locally: it carried
only st_uid/st_gid, and pathlib reads st_mode while walking. It now wraps
the real stat result and overrides ownership alone.
NOT taken: the CodeQL "information exposure through an exception" finding on
the hint response. Dropping `details` would contradict this package's
documented rule -- "if it returns 5xx, it says why" -- which
test_no_api_v3_handler_discards_its_exception enforces with an allowance
that may shrink and never grow. The Starlark routes are the ones that policy
was written for: they answered 500 with no detail for three releases.
describe_exception already redacts credentials and truncates. Keeping the
detail is the deliberate trade-off, so the finding is declined rather than
silently worked around.
Verified on hdpi with the updated code: a symlink to /etc/shadow planted in
starlark-apps was skipped while the directory was handed back, and
/etc/shadow stayed root:shadow.
Mutation-checked all three behaviours. The symlink test was vacuous on the
first attempt -- the link already had the target owner, so it was skipped
for the wrong reason and the mutation passed. It now forces the link to look
like it needs handing over, and fails when the check is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ API Version: 1.0.0
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
import fcntl
|
||||
from pathlib import Path
|
||||
@@ -457,12 +458,77 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
apps_dir = project_root / "starlark-apps"
|
||||
except Exception:
|
||||
# Fallback to current working directory
|
||||
apps_dir = Path.cwd() / "starlark-apps"
|
||||
project_root = Path.cwd()
|
||||
apps_dir = project_root / "starlark-apps"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
apps_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._hand_apps_dir_to_checkout_owner(apps_dir, project_root)
|
||||
return apps_dir
|
||||
|
||||
def _hand_apps_dir_to_checkout_owner(self, apps_dir: Path, project_root: Path) -> None:
|
||||
"""Give the apps directory to whoever owns the checkout.
|
||||
|
||||
This directory is not in the repository, so it is created lazily by
|
||||
whichever process reaches it first -- and the two that do run as
|
||||
different users. The display service is `User=root`
|
||||
(systemd/ledmatrix.service) and instantiates this plugin at startup,
|
||||
which is where `_get_apps_directory` is called from. The web interface
|
||||
is `User=<login user>` (systemd/ledmatrix-web.service) and is what
|
||||
actually installs apps.
|
||||
|
||||
On a fresh install the display service usually wins that race -- the
|
||||
documented first step is to install pixlet and reboot -- so the
|
||||
directory lands root-owned, and every subsequent install from the web
|
||||
UI fails on PermissionError. The user sees only "Failed to install
|
||||
from repository", with nothing pointing at ownership.
|
||||
|
||||
The web user cannot repair this; it lacks permission to chown. Root
|
||||
can, so root does it here, on every startup. That also heals installs
|
||||
already broken by this, without the user having to find the chown.
|
||||
"""
|
||||
geteuid = getattr(os, "geteuid", None)
|
||||
chown = getattr(os, "chown", None)
|
||||
if geteuid is None or chown is None or geteuid() != 0:
|
||||
# Not root, or not a platform with POSIX ownership. If the
|
||||
# directory is wrong we cannot fix it, and must not pretend to.
|
||||
return
|
||||
try:
|
||||
owner = project_root.stat()
|
||||
except OSError:
|
||||
return
|
||||
if owner.st_uid == 0:
|
||||
# The checkout genuinely belongs to root, so root owning the apps
|
||||
# directory is correct and there is nobody to hand it to.
|
||||
return
|
||||
|
||||
# Deepest first, with the directory itself last. Handing over the
|
||||
# container before its contents would briefly let a local user rename
|
||||
# entries underneath a repair that is still running.
|
||||
descendants = sorted(apps_dir.rglob("*"),
|
||||
key=lambda p: len(p.parts), reverse=True)
|
||||
for path in (*descendants, apps_dir):
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
# Never hand over a link's target. Anyone able to write in
|
||||
# this directory could otherwise point a symlink at a
|
||||
# root-owned file and have this give it away -- the whole
|
||||
# point of the loop is that it runs as root.
|
||||
continue
|
||||
if st.st_uid == owner.st_uid and st.st_gid == owner.st_gid:
|
||||
continue
|
||||
try:
|
||||
chown(path, owner.st_uid, owner.st_gid, follow_symlinks=False)
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not hand %s to uid %s: %s -- installs from the web "
|
||||
"interface will fail until this is chowned manually",
|
||||
path, owner.st_uid, e,
|
||||
)
|
||||
|
||||
def _sanitize_app_id(self, app_id: str) -> str:
|
||||
"""
|
||||
Sanitize app_id into a safe slug for use in file paths.
|
||||
@@ -1011,6 +1077,13 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})")
|
||||
return True
|
||||
|
||||
except PermissionError:
|
||||
# Deliberately not folded into the False below. A False here is
|
||||
# reported as a generic install failure, which is how the
|
||||
# directory-ownership bug stayed invisible: the caller could not
|
||||
# tell "this app is broken" from "this process cannot write here".
|
||||
# The routes turn this into a message that names the fix.
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error installing app {app_id}: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user