Repository navigation
Command injection in filesystem.py: nine shell commands interpolate user paths, zero shlex.quote #154
Description
Summary
clustrix/filesystem.py builds nine remote shell commands by f-string interpolation of caller-supplied paths and glob patterns, and contains no shlex.quote at all:
$ grep -c shlex clustrix/filesystem.py
0
423: cmd = f"ls -1 {full_path} 2>/dev/null || true"
437: cmd = f"cd {full_path} && find . -name '{pattern}' -type f | sed 's|^\\./||' | sort"
452: cmd = f"stat -c '%s %Y %f' {full_path} 2>/dev/null"
483: cmd = f"test -e {full_path} && echo 'EXISTS' || echo 'NOT_EXISTS'"
494: cmd = f"test -d {full_path} && echo 'DIR' || echo 'NOT_DIR'"
505: cmd = f"test -f {full_path} && echo 'FILE' || echo 'NOT_FILE'"
518: cmd = f"cd {full_path} && ls -d {pattern} 2>/dev/null | sort || true"
553: cmd = f"find {full_path} -type f 2>/dev/null | wc -l"
556: cmd = f"find {full_path} -name '{pattern}' -type f 2>/dev/null | wc -l"Nothing sanitizes the input on the way in. _get_full_path (filesystem.py:232) only joins the caller's string to a base directory and returns it:
if os.path.isabs(path):
return path
return os.path.join(base_dir, path)This is public API
Every one of these is reachable through a documented, exported convenience function: cluster_ls, cluster_find, cluster_stat, cluster_exists, cluster_isdir, cluster_isfile, cluster_glob, cluster_du, cluster_count_files — all in clustrix.__all__ and all documented in docs/source/api/filesystem.rst.
A path or pattern carrying shell metacharacters runs as a command on the cluster, as the user, with their credentials. The single-quoted -name '{pattern}' sites are not protection: a pattern containing ' closes the quote.
Why it survived
This is precisely the defect class PR #139 fixed — its description says "the job script had no shlex.quote anywhere, so remote_work_dir, module loads and environment variables reached the shell unquoted." That work hardened clustrix/utils.py, which now carries 56 shlex.quote calls and a validate_shell_fragment allowlist, and it added tests/unit/test_script_injection.py to hold the line.
filesystem.py was never audited in that pass. The same author-supplied-string-to-shell pattern sat one module over the whole time.
Two more defects in the same function
While confirming the above:
-
_remote_statis not portable, and its comment says it is. Line 451 reads# Use stat command with portable format; line 452 usesstat -c, which is GNU coreutils only. BSD and macOSstattake-fand reject-c. On such a host the command fails,2>/dev/nullswallows the error,outputis empty, and the caller getsFileNotFoundError: File not found: <path>for a file that exists. The2>/dev/nullturns every failure mode of this command into a false "not found". -
SFTP would remove the whole problem.
paramiko'sSFTPClient.stat()returns size, mtime and mode over the protocol — no shell, no quoting, no GNU-vs-BSD difference.ConnectionManageralready opens SFTP channels (executor_connections.py:152)._remote_stat,_remote_exists,isdirandisfileare all better served by SFTP than bytest/stat.
Definition of done
- No shell command in
filesystem.pyinterpolates an unquoted caller-supplied value - Where SFTP can replace a shell command outright, it does — that is a removal of the attack surface rather than an escape of it
-
2>/dev/nullno longer converts a command failure into a false negative; a failure says what failed -
_remote_statworks on a non-GNU remote host, or refuses with a message naming the reason - Injection tests in the style of
tests/unit/test_script_injection.py, exercised against the real in-process SSH server intests/ssh_server.py— hostile paths must be treated as filenames, never executed - A guard that fails if a new unquoted interpolation appears in this module
Activity
jeremymanning commentedon Aug 20, 2026
Closed — the shell is gone from seven of nine sites
Seven operations now go over SFTP:
ls,stat,exists,isdir,isfile,glob,du. That is removal of the attack surface rather than escaping it. Onlyfindstill shells out, because walking a tree over SFTP costs a round trip per directory; both its directory and its pattern areshlex.quoted, and it uses-print0so a newline in a filename is not two results.2>/dev/nullis gone everywhere — it had been converting every command failure into a false "not found", including the GNU-onlystat -cfailing outright on a BSD or macOS worker.An adversarial review threw 14 payload families at it —
-,--,-exec,$IFS,~root, trailing backslash, process substitution,$'..', heredoc, newline-in-pattern,../../../etc/passwd— across both paths and patterns. Nothing executed.That review also found the first anti-regression guard nearly blind: it matched variable names, so
self._run_remote(f"ls -1 {full_path}")— the module's own primary helper — was exploitable and reported clean. The guard now tracks tainted values and discovers sinks by fixpoint. All eight demonstrated bypasses fail it.tests/unit/test_filesystem_injection.py(68 tests) includes regressions that run the pre-fix command strings verbatim and watch a sentinel appear, so the tests cannot pass vacuously.Two behaviour regressions the review caught in the SFTP rewrite are fixed too:
glob("*/")returns directories only again (_local_globis the oracle, 22 patterns asserted equal), anddumatchesos.walk(followlinks=False)so a symlink to an ancestor terminates.Issue #154 follow-up: make the injection guard see, and make remote m…