/api/modules/uninstall deletes a module directory by joining a
caller-supplied slug onto the shared parent directory that holds every
installed module, then rejects the slug only if it contains "/" or "..".
A slug of exactly "." passes that check -- it is not a traversal string,
it is the current directory -- and os.path.join(modules_dir, ".")
resolves to modules_dir itself. The route then runs shutil.rmtree() on
that path.
shutil.rmtree() deletes a directory's CONTENTS before it removes the
directory itself. On Linux, removing a path that resolves to "." fails
at that very last step with EINVAL ("Invalid argument") -- by which point
every module subdirectory and every file inside it is already gone; only
the now-empty parent directory survives. The route's except-block reports
this as {"ok": false, "error": "rmtree failed: ..."} -- read literally,
that response says the uninstall did NOT happen. It did: every installed
module was just deleted. Because the exception triggers an early return,
the two lines immediately after the try/except (a modules reload and an
audit_log() call) never execute, so there is no audit trail of the
deletion either -- an operator has no record of what happened beyond an
empty modules/ folder and a response that told them nothing was removed.
Reproduction steps:
- Extract a clean station-v0.80 tarball, sys.path.insert(0, "workbench").
- Create a scratch modules/ directory containing two ordinary installed
module subdirectories with real files inside them (standing in for
two genuinely installed modules).
- Call routes.dispatch_admin._handle_modules_uninstall({"slug": "."},
handler) -- the exact function /api/modules/uninstall dispatches to,
through a session-authenticated handler stub.
Expected: the request is refused as an invalid slug (the same outcome a
slug containing ".." already gets), and every installed module is left
untouched.
Actual: both installed modules and their files are deleted. The API
response reads {"ok": false, "error": "rmtree failed: [Errno 22] Invalid
argument: '.../modules/.'"} -- appearing to report that nothing happened
-- and no audit_log entry is written for the deletion.
Root cause: workbench/routes/dispatch_admin.py _handle_modules_uninstall()
validates slug against not slug or "/" in slug or ".." in slug only,
which does not reject slug == "." (or other filesystem self-references
os.path.join treats as a no-op join). shutil.rmtree()'s content-then-
container deletion order means the destructive part of the operation
completes before the exception that reports the whole thing as failed.
Suggested fix: validate slug against a positive allowlist (e.g.
matching the same filename-safe pattern this codebase already uses
elsewhere, ^[A-Za-z0-9_-]{1,80}$) instead of a denylist of specific
bad substrings, so any value that isn't a plain, real module directory
name -- "." included -- is rejected before it ever reaches
os.path.join()/shutil.rmtree(). Separately, log the exception before
returning, not just on the success path, so a failed-but-partially-
destructive operation still leaves an audit trail.