fix(sessions,model): reject embedded filenames that aren't a safe single path component - #359
fix(sessions,model): reject embedded filenames that aren't a safe single path component#359epmog wants to merge 2 commits into
Conversation
53f6b1b to
bcd8b00
Compare
bcd8b00 to
ce1ddc5
Compare
ce1ddc5 to
f97da38
Compare
f97da38 to
7b05b7f
Compare
7b05b7f to
4af0c16
Compare
4af0c16 to
92b49b6
Compare
| (components.next(), components.next()), | ||
| (Some(Component::Normal(_)), None) | ||
| ) { | ||
| return Err("must be a single path component".into()); |
There was a problem hiding this comment.
The Component::Normal rule is now unreachable on both platforms, and the spec/doc examples attached to it are no longer accurate.
By the time control reaches this check the input has already been filtered for / and \, \0, exactly ./.., and — on Windows — any : plus all-dots-and-spaces. On Windows a non-Normal leading component requires either a separator (RootDir, UNC/verbatim prefixes) or a colon (drive prefix), both already rejected above. On POSIX only / or ./.. can yield a non-Normal component. So components() always yields exactly one Component::Normal here and this branch cannot fire.
That is fine as a fail-closed backstop, but the documentation presents it as the rule that catches drive-relative paths, and that is no longer true:
specs/sessions/embedded-files.md:104— the table rownot a single "normal" path component (e.g. D:relative, C:)- the doc comment at
embedded_files.rs:202-208— "most notably Windows drive-relative paths likeD:relative"
The new tests contradict both: rejects_windows_drive_relative and rejects_windows_bare_drive assert the reason is the colon message, not must be a single path component. A maintainer reading the spec would conclude drive-relative handling rests on Path::components parsing for the host OS, when it now rests entirely on the colon rule — which matters, because the colon rule is #[cfg(windows)]-gated while this component check is not.
Suggest attributing D:relative/C: to the colon rule in both the spec row and the doc comment, and describing the component check as a backstop with no currently-reaching input.
| /// created: even if filename validation were bypassed or a future change let | ||
| /// something through, the path we are about to write must still sit within the | ||
| /// session files directory. Comparison is lexical (see [`normalize_lexical`]), | ||
| /// so it holds regardless of whether the paths exist on disk yet. |
There was a problem hiding this comment.
Scoping question on the "final defense-in-depth guarantee" wording: because the check is purely lexical, it does not hold in the cross-user configuration, and the doc/spec text reads as if it does.
TempDir::new creates the embedded-files directory 0o770 when user is a non-process user (tempdir.rs:175-190), i.e. group-writable by the session user. So the session user can pre-create a symlink in that directory at the name a template declares (filename: "script.sh"), and write_file_contents reaches the file via fs::write (embedded_files.rs:522, write_embedded_file_with_options → fs::write), which follows symlinks. The resulting write lands wherever the link points, as the process user — outside the session files directory — while ensure_within reports the path as contained, because it explicitly does no symlink resolution.
The same applies to the fs::write(&path, b"") + set_permissions in the unnamed branch, though the random 32-hex name makes that far harder to pre-empt.
This is pre-existing behavior, not introduced here — the concern is that this change adds a claim that is stronger than what the code provides. specs/sessions/embedded-files.md now says the resulting path "must still lie within the session files directory ... so a bug there cannot lead to a write outside the session directory", and this doc comment calls it "the final defense-in-depth guarantee". Both are true only for lexical/.. escapes, not for symlinks.
Either narrow the wording (state that symlink-based escapes are out of scope and why, next to the existing out-of-scope note for reserved device names), or make the write itself refuse to follow a link — e.g. OpenOptions::new().write(true).create_new(true) for the initial creation, plus custom_flags(libc::O_NOFOLLOW) on unix.
| .as_nanos() | ||
| ); | ||
| let rel_root = std::path::PathBuf::from(&rel_name); | ||
| std::fs::create_dir(&rel_root).unwrap(); |
There was a problem hiding this comment.
This creates a directory inside the source tree. The test resolves against the process CWD, which for cargo test/cargo nextest is the crate manifest directory — so this writes crates/openjd-sessions/openjd_rel_root_test_<pid>_<nanos>/ into the checkout. The Cleanup guard handles panics, but not a killed/aborted test process (SIGKILL, CI timeout, --no-capture interrupt), leaving an untracked directory behind.
Two smaller points on the same test:
- The whole session working directory tree lives under this relative root, so anything that leaks is a full session dir, not an empty one.
- The assertion (
working_directory().is_absolute()) does not exercise the behavior the doc comment describes as the regression — that a named embedded file now succeeds under a relative root.ensure_withinis applied tofiles_directory, and the test never allocates an embedded file, so a future change that absolutizedworking_directorybut notfiles_directorywould still pass.
Both are addressable without the CWD dependency: construct a PathBuf from a tempfile::TempDir path with a ./.. segment spliced in (e.g. <tmp>/x/../x) so std::path::absolute has something to normalize, or drop the Session layer and assert on EmbeddedFiles::new + allocate_file_paths with a relative target directory directly — which also covers the named-file case.
leongdl
left a comment
There was a problem hiding this comment.
Please reply to the approved comments.
|
Reviewed at TLDRThe change is correct and well-tested, and it closes real gaps. Two things worth raising, one misleading-but-cosmetic and one substantive: 1. The
On POSIX, The doc comment, 2. The containment guarantee claimed is stronger than what holds, because none of this is symlink-safe. Probe against the crate's own public write path, with a symlink planted at
I proved the mechanism; I did not stand up two users, so the How it works — the two call stacksTemplate validation, Reached from two call sites, Session materialization, The load-bearing design fact is at the top of |
92b49b6 to
2b3dddf
Compare
Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com>
Signed-off-by: Morgan Epp <60796713+epmog@users.noreply.github.com>
2b3dddf to
7e89e0b
Compare
| /// Create a new embedded-file materializer targeting | ||
| /// `session_files_directory`. | ||
| /// | ||
| /// `session_files_directory` must be an absolute path. The final |
There was a problem hiding this comment.
This doc comment introduces "session_files_directory must be an absolute path" as a precondition, but new does not enforce it and there are public entry points that do not satisfy it — so the violation surfaces later as a misleading error rather than at construction.
Session::with_config is not the only way to reach EmbeddedFiles::new with a caller-supplied directory:
runner::step_script::StepScriptRunner::new(session_id, working_directory, files_directory, user)(step_script.rs:31) ispubin apub mod, and passesfiles_directorystraight through toEmbeddedFiles::newatstep_script.rs:132.runner::env_script::EnvironmentScriptRunner::new(env_script.rs:41) does the same atenv_script.rs:232andenv_script.rs:257.Session::new_for_test(session.rs:520-524) derivesfiles_directory = working_directory.join("embedded_files")from a caller-suppliedworking_directorywith no absolutization; thetest-utilsfeature that gates it is enabled for this crate's own integration tests.
None of those paths go through the std::path::absolute call added in with_config. An external consumer that constructs a runner directly with a relative files_directory now gets, for every named embedded file:
Embedded file 'X' has unsafe filename 'script.sh': path containment can only be checked for rooted paths
which blames the filename when the actual problem is the target directory — the same spurious-error mode the with_config change was written to fix, just reached by a different door. Before this PR those call paths worked with a relative directory.
Two ways to make the stated precondition hold rather than be assumed:
- Absolutize in
EmbeddedFiles::newitself (std::path::absoluteonsession_files_directory), which covers every entry point at once and letswith_config's change stay purely about logging/derived-path clarity.newcurrently returnsSelf, so this needs either an infallible fallback or a signature change. - Or keep
newinfallible and make the failure legible: giveensure_within's rooted-path rejection its ownSessionErrorvariant that names the directory, instead of reusingEmbeddedFilePath { filename, .. }.
What was the problem/requirement? (What/Why)
An embedded file's
filenamemust be a plain, single path component per the2023-09 spec — directory pathing is disallowed. Several gaps let unsafe values
through:
/and\. It accepted.,.., and filenames containing a null byte.session files directory, had no check for strings that contain no separator
yet are still not plain basenames — most notably Windows drive-relative
anchors like
D:relativeand bare drives likeC:.Path::jointreatsthese as a drive prefix and would resolve to a location outside the target
directory.
filename made up entirely of dots and spaces (
".. ","...",". ")collapses to
.or..at the OS layer and resolves to the current/parentdirectory. Rust's
Pathonly special-cases the exact strings./.., sothese slipped past the checks above as ordinary components. (On POSIX these
are legitimate, distinct filenames that stay inside the directory.)
What was the solution? (How)
openjd-model): extend embedded-filefilenamevalidationto also reject a null character and the values
.and.., withPydantic-compatible error paths/messages.
openjd-sessions): require the resolved filename to beexactly one "normal" path component for the host platform (rejecting Windows
drive-relative anchors and bare drives, which
Pathparses as a prefix/rooton Windows and as ordinary filenames on POSIX). On Windows, additionally
reject filenames that consist entirely of dots and spaces, which the OS would
collapse to
./... As a final defense-in-depth guarantee, the joined pathis lexically normalized (collapsing
./..without touching the filesystem)and must still resolve to a location inside the session files directory
before the file is created; otherwise a
SessionError::EmbeddedFilePathisraised. This containment check now requires both paths to be rooted (which
target_directoryalways is), because lexical..handling is only soundfor rooted paths.
The Windows-specific rules (drive-relative anchors, all-dots-and-spaces names)
are intentionally enforced in the sessions layer rather than the model layer:
template validation is platform-independent (a template authored on Linux may
target Windows), while the sessions layer knows the execution host and can
parse path components correctly for it. The corresponding strings are valid,
contained filenames on POSIX, so rejecting them everywhere would forbid
legitimate names for no security benefit.
What is the impact of this change?
Templates with an embedded-file
filenameof.,.., or containing a nullbyte are now rejected at validation time with a clear error. At session time,
filenames that are not a single safe path component for the host — Windows
drive-relative paths, and Windows names made only of dots and spaces — are
rejected before any file is written, closing paths that could escape the
session files directory. Legitimate basename filenames are unaffected on every
platform.
How was this change tested?
cargo test -p openjd-sessions— 196 unit + 287 integration + 6 doc testspassed.
cargo test -p openjd-model(embedded-filename tests) — pass.cargo clippy --all-targets --workspace -- -D warnings— clean.cargo fmt --all -- --check— clean..,.., and null-character filenames.lexical-normalization/containment helpers (including rejection of relative,
non-rooted paths), and the Windows all-dots-and-spaces rule; plus Windows-only
integration tests for drive-relative anchors (
D:relative,C:thing.exe,C:) and all-dots-and-spaces names (".. ","...").#[cfg(windows)], so those tests compile and runon the Windows CI job.
against the pre-change source in a temporary worktree: all three failed
(templates parsed successfully where an error was expected), while the
pre-existing filename tests continued to pass.
Was this change documented?
validate_resolved_filename,normalize_lexical, andensure_withindocstrings describe the rules and rationale (including the Windows
all-dots-and-spaces rule and the rooted-path precondition).
specs/model/validation.mdnow lists the full embedded-file
filenamerules (non-empty, no separators,no null, not
./..), andspecs/sessions/embedded-files.mddocuments thesingle-path-component rule, the Windows all-dots-and-spaces rule, and the
final containment check.
Is this a breaking change?
No. This tightens validation to reject inputs that were never valid per the
spec. No public API signatures change, and previously-valid basename filenames
continue to work. (Templates that relied on the previously-accepted-but-invalid
./../null filenames — or, on Windows, drive-relative and all-dots-and-spacesfilenames — will now be rejected, which is the intended fix.)
Does this change impact security?
Yes — this is a path-traversal hardening change. It adds defense-in-depth so an
embedded file cannot be written outside the session files directory, including
a final containment check on the resolved path that is now sound only for
rooted paths, and Windows-specific rejections for names the OS would otherwise
collapse to a parent/current-directory reference. It does not change file
ownership or permissions semantics.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.