-
Notifications
You must be signed in to change notification settings - Fork 60
[WSLC] Alias cannonicalization follow-up in denied-path overlap validator #680
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -185,10 +185,11 @@ impl NormalizedPath { | |
| /// Excluding exact match here is deliberate: an exact same-*string* deny==mount | ||
| /// is already collapsed most-restrictive-wins at parse time | ||
| /// (`normalize_filesystem_paths`), and an exact same-*object* alias (a deny | ||
| /// that canonicalizes onto the mount root) is collapsed by D6 | ||
| /// (`normalize_object_conflicts`) before this validator runs. This check | ||
| /// therefore only owns the *strictly-nested* overlap that neither of those | ||
| /// layers can resolve (deny under a mount = distinct objects). | ||
| /// that canonicalizes onto the mount root) is normally collapsed by D6 | ||
| /// (`normalize_object_conflicts`) before this validator runs. This method | ||
| /// therefore only owns the *strictly-nested* overlap; the Tier-2 caller | ||
| /// separately rejects alias-induced canonical equality (differing original | ||
| /// strings) that D6 missed as absent. | ||
| fn contains_strictly(&self, child: &NormalizedPath) -> bool { | ||
| if self.rooted && self.components.is_empty() { | ||
| // Whole-drive mount: covers the entire drive, so any same-anchor path | ||
|
|
@@ -252,13 +253,13 @@ impl NormalizedPath { | |
| /// `C:\secrets` through `C:\project\link` if the runtime follows the reparse | ||
| /// point. Detecting this would require walking the mounted subtree for reparse | ||
| /// points (expensive and still racy) or controlling traversal beneath the mount, | ||
| /// which the WSLC SDK does not expose. Likewise, Tier 2 does not fold Unicode | ||
| /// normalization forms and compares path endpoints, not object identity: a hard | ||
| /// link inside a mounted tree pointing at a denied file resolves to its own | ||
| /// in-tree name (not the denied one), so it is not caught here or by D6 (the two | ||
| /// are distinct objects). Creating either alias requires write access to a | ||
| /// location the guest does not have, so both are out of scope under the | ||
| /// trusted-author threat model. A residual TOCTOU window also remains between | ||
| /// which the WSLC SDK does not expose. The same gap applies to a hard link: since | ||
| /// hard links are names for one object, hard-linked *policy entries* are caught by | ||
| /// D6's file-ID grouping (`normalize_object_conflicts`); only an *unlisted* hard | ||
| /// link inside a mounted subtree escapes both Tier 2 and D6. Creating either alias | ||
| /// needs write access the guest lacks, so both are out of scope under the | ||
| /// trusted-author threat model. Tier 2 also does not fold Unicode normalization | ||
| /// forms. A residual TOCTOU window remains between | ||
| /// canonicalization and the SDK mount (an alias could be swapped in between); | ||
| /// fully closing it needs handle-based mounting the WSLC SDK does not expose, so | ||
| /// it is likewise accepted. | ||
|
|
@@ -354,11 +355,29 @@ fn validate_denied_path_overlap_with( | |
| continue; // Absent: no on-disk ancestor resolved, so nothing to alias. | ||
| }; | ||
| let denied_norm = NormalizedPath::parse(denied_resolved); | ||
| let denied_lexical = NormalizedPath::parse(denied); | ||
| for (mounted, list_name, mount_canon) in &mount_canon { | ||
| let PathCanonical::Canonical(mount_resolved) = mount_canon else { | ||
| continue; | ||
| }; | ||
| if NormalizedPath::parse(mount_resolved).contains_strictly(&denied_norm) { | ||
| let mount_norm = NormalizedPath::parse(mount_resolved); | ||
| // Strict nesting is always an unenforceable overlap: the denied | ||
| // subtree lives inside a flat mount that WSLC cannot mask. | ||
| if mount_norm.contains_strictly(&denied_norm) { | ||
| return Err(overlap_error(denied, mounted, list_name, true)); | ||
| } | ||
| // Exact canonical equality is an overlap only when it comes from an | ||
| // on-disk alias — the two spellings normalize to *different lexical* | ||
| // forms yet resolve to the same object. A case/separator/`..` variant | ||
| // of the same spelling folds to an equal `NormalizedPath` here (via | ||
| // `NormalizedPath::parse`, NOT `normalize_filesystem_paths`, which only | ||
| // dedupes byte-identical strings) and is the enforceable exact-match | ||
| // case (enforced by not mounting the path), so accept it. But a deny | ||
| // that aliases onto the mount root via a symlink, junction, or short | ||
| // name has no exclusion primitive — `contains_strictly` (strictly | ||
| // deeper only) misses that, so reject it here. | ||
| let mount_lexical = NormalizedPath::parse(mounted); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new discriminator is only ever exercised in one direction, so the regression that would silently break it isn't detectable by the suite. let mount_lexical = NormalizedPath::parse(mounted);
if mount_norm == denied_norm && mount_lexical != denied_lexical {The reject side is well covered — That leaves a specific mutation undetected: if mount_norm == denied_norm && mounted != denied { // raw &String compareThe entire suite still passes. The only accept-case uses identical strings, for which a raw comparison and a Deleting the clause outright is caught by the existing test, so the hole is specifically lexically-different-but-normalization-equal spellings — exactly the set Suggested tests — note #[test]
fn canonical_allows_case_and_separator_variant_exact_match() {
// `NormalizedPath::parse` case-folds and folds `/` vs `\`, so these are the
// same spelling, not an on-disk alias: enforceable by not mounting → accept.
let resolve = resolver(vec![
(r"C:\Project", canonical(r"C:\project")),
("c:/project", canonical(r"C:\project")),
]);
validate_denied_path_overlap_with(
&strings(&[r"C:\Project"]),
&[],
&strings(&["c:/project"]),
resolve,
)
.expect("case/separator variant of the same path must be accepted");
}
#[test]
fn canonical_allows_dot_dot_variant_exact_match() {
// `C:\x\..\project` folds to `C:\project` lexically — same spelling, not an alias.
let resolve = resolver(vec![
(r"C:\project", canonical(r"C:\project")),
(r"C:\x\..\project", canonical(r"C:\project")),
]);
validate_denied_path_overlap_with(
&strings(&[r"C:\project"]),
&[],
&strings(&[r"C:\x\..\project"]),
resolve,
)
.expect("`..`-variant of the same path must be accepted");
}They slot in right after |
||
| if mount_norm == denied_norm && mount_lexical != denied_lexical { | ||
|
|
||
| return Err(overlap_error(denied, mounted, list_name, true)); | ||
| } | ||
| } | ||
|
|
@@ -755,6 +774,93 @@ mod tests { | |
| assert!(err.contains("cannot be enforced"), "{err}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn canonical_rejects_denied_alias_equal_to_mount_root() { | ||
| // Absent-tail deny whose parent alias lands *exactly* on the mount root: | ||
| // D6 saw the deny as absent, but tail replay canonicalizes it onto the | ||
| // mount root. `contains_strictly` (strictly deeper only) misses this equal | ||
| // case, so Tier 2's explicit canonical-equality check must reject it. | ||
| let resolve = resolver(vec![ | ||
| (r"C:\real", canonical(r"C:\real")), | ||
| (r"C:\link\missing\..", canonical(r"C:\real")), | ||
| ]); | ||
| let err = validate_denied_path_overlap_with( | ||
| &strings(&[r"C:\real"]), | ||
| &[], | ||
| &strings(&[r"C:\link\missing\.."]), | ||
| resolve, | ||
| ) | ||
| .unwrap_err(); | ||
|
|
||
| assert!(err.contains("cannot be enforced"), "{err}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn canonical_allows_literal_exact_match_that_resolves() { | ||
| // A literal deny == mount (same original string) that both canonicalize to | ||
| // an existing object must still be accepted: parse-time normalization | ||
| // collapses it most-restrictive-wins, and it is enforceable by simply not | ||
| // mounting the path. Only alias-induced equality (differing originals) is | ||
| // rejected, so an identical string must survive Tier 2. | ||
| let resolve = resolver(vec![(r"C:\project", canonical(r"C:\project"))]); | ||
| validate_denied_path_overlap_with( | ||
| &strings(&[r"C:\project"]), | ||
| &[], | ||
| &strings(&[r"C:\project"]), | ||
| resolve, | ||
| ) | ||
| .expect("literal exact-match deny is enforceable and must be accepted"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn canonical_allows_case_and_separator_variant_exact_match() { | ||
| // `NormalizedPath::parse` case-folds and folds `/` vs `\`, so these are the | ||
| // same spelling, not an on-disk alias: enforceable by not mounting → accept. | ||
| let resolve = resolver(vec![ | ||
| (r"C:\Project", canonical(r"C:\project")), | ||
| ("c:/project", canonical(r"C:\project")), | ||
| ]); | ||
| validate_denied_path_overlap_with( | ||
| &strings(&[r"C:\Project"]), | ||
| &[], | ||
| &strings(&["c:/project"]), | ||
| resolve, | ||
| ) | ||
| .expect("case/separator variant of the same path must be accepted"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn canonical_allows_dot_dot_variant_exact_match() { | ||
| // `C:\x\..\project` folds to `C:\project` lexically — same spelling, not an alias. | ||
| let resolve = resolver(vec![ | ||
| (r"C:\project", canonical(r"C:\project")), | ||
| (r"C:\x\..\project", canonical(r"C:\project")), | ||
| ]); | ||
| validate_denied_path_overlap_with( | ||
| &strings(&[r"C:\project"]), | ||
| &[], | ||
| &strings(&[r"C:\x\..\project"]), | ||
| resolve, | ||
| ) | ||
| .expect("`..`-variant of the same path must be accepted"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn canonical_allows_trailing_separator_variant_exact_match() { | ||
| // `parse` drops empty segments, so a trailing separator is the same spelling. | ||
| let resolve = resolver(vec![ | ||
| (r"C:\project", canonical(r"C:\project")), | ||
| (r"C:\project\", canonical(r"C:\project")), | ||
| ]); | ||
| validate_denied_path_overlap_with( | ||
| &strings(&[r"C:\project"]), | ||
| &[], | ||
| &strings(&[r"C:\project\"]), | ||
| resolve, | ||
| ) | ||
| .expect("trailing-separator variant of the same path must be accepted"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_mounts_never_fail_closed() { | ||
| // With no mounts nothing can overlap, so an unresolvable deny must NOT | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,9 +42,10 @@ pub fn canonicalize_path(path: &str) -> PathCanonical { | |
| CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, HANDLE, | ||
| }; | ||
| use windows::Win32::Storage::FileSystem::{ | ||
| CreateFileW, GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, FILE_NAME_NORMALIZED, | ||
| FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, | ||
| GETFINALPATHNAMEBYHANDLE_FLAGS, OPEN_EXISTING, VOLUME_NAME_DOS, | ||
| CreateFileW, GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, | ||
| FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, | ||
| FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GETFINALPATHNAMEBYHANDLE_FLAGS, | ||
| OPEN_EXISTING, VOLUME_NAME_DOS, | ||
| }; | ||
|
|
||
| // RAII guard so the handle is closed on every exit path, including an | ||
|
|
@@ -83,12 +84,53 @@ pub fn canonicalize_path(path: &str) -> PathCanonical { | |
| // the error captured by the failed call itself (no second, racy | ||
| // GetLastError round-trip). | ||
| let code = e.code(); | ||
| return if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0) | ||
| || code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0) | ||
| if code != HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0) | ||
| && code != HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0) | ||
| { | ||
| PathCanonical::Absent | ||
| } else { | ||
| PathCanonical::Unknown | ||
| // Exists but unexaminable (access denied, I/O error, …). | ||
| return PathCanonical::Unknown; | ||
| } | ||
| // "Not found" from the open above is ambiguous: that open *follows* | ||
| // reparse points, so a dangling symlink/junction (the link object | ||
| // exists but its target is missing) also reports FILE/PATH_NOT_FOUND. | ||
| // Re-probe without following the reparse point; if the link itself | ||
| // opens, the path exists but cannot be resolved — classify it Unknown | ||
| // so denied-path callers fail closed rather than replay a stale alias. | ||
| // SAFETY: `wide` is a local NUL-terminated buffer; all other pointers | ||
| // are NULL. | ||
| let reparse = unsafe { | ||
| CreateFileW( | ||
| PCWSTR(wide.as_ptr()), | ||
| FILE_READ_ATTRIBUTES.0, | ||
| share, | ||
| None, | ||
| OPEN_EXISTING, | ||
| FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, | ||
| None, | ||
| ) | ||
| }; | ||
| return match reparse { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reparse re-probe fails open on any outcome that isn't a clean success or a true "not found", and the downstream effect is worse than a skipped check — it manufactures a confidently wrong canonical path. The wildcard arm collapses every non- return match reparse {
Ok(h) if !h.is_invalid() => { let _guard = OwnedHandle(h); PathCanonical::Unknown }
// Nothing here at all (or a genuinely missing parent): absent.
_ => PathCanonical::Absent,
};
That directly contradicts the documented contract on the type being returned ( /// Cleanly missing — no object exists, so there is nothing to alias.
Absent,
/// Present (or maybe present) but unresolvable: access denied, I/O error, or
/// an unsupported build target. Callers fail closed on this when denies apply.
Unknown,"Access denied" is enumerated under Why this is a deny bypass rather than a conservative skip. The consumer is
So the validator doesn't skip anything — it receives a fully-confident canonical path that still contains the unresolved junction, compares that literal spelling against the mount list, finds no overlap, and accepts the config. The junction's actual target inside the mounted subtree is never considered. Note This is precisely the outcome the comment on the probe is written to prevent — "classify it Unknown so denied-path callers fail closed rather than replay a stale alias." The wildcard defeats that intent in exactly the situation it describes. Fix — only a genuine not-found is return match reparse {
// The link object itself exists: present but unresolvable → fail closed.
Ok(h) if !h.is_invalid() => {
let _guard = OwnedHandle(h);
PathCanonical::Unknown
}
// Success with an invalid handle: no error to read, so unexaminable.
Ok(_) => PathCanonical::Unknown,
Err(e) => {
let code = e.code();
if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0)
|| code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0)
{
// Nothing here at all (or a genuinely missing parent): absent.
PathCanonical::Absent
} else {
// Present but unexaminable (access denied, sharing violation, I/O) → fail closed.
PathCanonical::Unknown
}
}
};Once that's in, For coverage, the higher-value regression test is at the |
||
| // The link object itself exists: present but unresolvable → | ||
| // fail closed. Drop the probe handle immediately via the guard. | ||
| Ok(h) if !h.is_invalid() => { | ||
| let _guard = OwnedHandle(h); | ||
| PathCanonical::Unknown | ||
| } | ||
| // Success with an invalid handle: no error to read → unexaminable. | ||
| Ok(_) => PathCanonical::Unknown, | ||
| Err(e) => { | ||
| let code = e.code(); | ||
| if code == HRESULT::from_win32(ERROR_FILE_NOT_FOUND.0) | ||
| || code == HRESULT::from_win32(ERROR_PATH_NOT_FOUND.0) | ||
| { | ||
| // Genuinely missing (or a missing parent): absent. | ||
| PathCanonical::Absent | ||
| } else { | ||
| // Present but unexaminable (access denied, sharing | ||
| // violation, I/O) → fail closed. | ||
| PathCanonical::Unknown | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| // CreateFileW reported success but returned an invalid handle: there is | ||
|
|
@@ -354,6 +396,96 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| #[cfg(windows)] | ||
| #[test] | ||
| fn dangling_reparse_point_is_unknown_not_absent() { | ||
| // A junction whose target is missing exists as an object but makes the | ||
| // *followed* open report FILE/PATH_NOT_FOUND. It must classify Unknown | ||
| // (present-but-unresolvable → fail closed), never Absent, so denied-path | ||
| // callers do not treat it as cleanly-missing and replay a stale alias. | ||
| use std::process::Command; | ||
|
|
||
| // Thread id keeps concurrent tests from colliding on the fixture dir. | ||
| let tmp = std::env::temp_dir(); | ||
| let base = format!( | ||
| r"{}\mxc-dangling-{}-{:?}", | ||
| tmp.to_string_lossy().trim_end_matches('\\'), | ||
| std::process::id(), | ||
| std::thread::current().id() | ||
| ); | ||
| let link = format!(r"{base}\link"); | ||
| let missing_target = format!(r"{base}\no-such-target"); | ||
| let _ = std::fs::remove_dir_all(&base); // clear any leftover from an aborted run | ||
| std::fs::create_dir_all(&base).unwrap(); | ||
|
|
||
| // `mklink /J` creates a junction even when the target does not exist. | ||
| let status = Command::new("cmd") | ||
| .args(["/c", "mklink", "/J", &link, &missing_target]) | ||
| .status() | ||
| .unwrap(); | ||
| // mklink /J needs no elevation, so a failure is a broken environment, | ||
| // not an expected skip — surface it rather than silently no-op. | ||
| assert!( | ||
| status.success(), | ||
| "mklink /J failed ({status}); junction creation needs no elevation, so this \ | ||
| indicates a broken test environment rather than an expected skip" | ||
| ); | ||
|
|
||
| let result = canonicalize_path(&link); | ||
| // Remove the junction itself (do not follow it) before clearing base. | ||
| let _ = std::fs::remove_dir(&link); | ||
| let _ = std::fs::remove_dir_all(&base); | ||
|
|
||
| assert_eq!( | ||
| result, | ||
| PathCanonical::Unknown, | ||
| "a dangling junction must be Unknown (fail closed), not Absent" | ||
| ); | ||
| } | ||
|
|
||
| #[cfg(windows)] | ||
| #[test] | ||
| fn dangling_junction_ancestor_is_unknown_not_canonical() { | ||
| // Chain-level guard where the security property lives: a deny under a | ||
| // dangling-junction *ancestor* must fail closed, never replay the absent | ||
| // tail onto a resolved grandparent and return Canonical(<path with the | ||
| // junction>) — which the overlap check would then miss. | ||
| use std::process::Command; | ||
|
|
||
| let tmp = std::env::temp_dir(); | ||
| let base = format!( | ||
| r"{}\mxc-dangling-anc-{}-{:?}", | ||
| tmp.to_string_lossy().trim_end_matches('\\'), | ||
| std::process::id(), | ||
| std::thread::current().id() | ||
| ); | ||
| let link = format!(r"{base}\link"); | ||
| let missing_target = format!(r"{base}\no-such-target"); | ||
| let leaf = format!(r"{link}\file"); | ||
| let _ = std::fs::remove_dir_all(&base); | ||
| std::fs::create_dir_all(&base).unwrap(); | ||
|
|
||
| let status = Command::new("cmd") | ||
| .args(["/c", "mklink", "/J", &link, &missing_target]) | ||
| .status() | ||
| .unwrap(); | ||
| assert!( | ||
| status.success(), | ||
| "mklink /J failed ({status}); junction creation needs no elevation" | ||
| ); | ||
|
|
||
| let result = canonicalize_allowing_absent_tail(&leaf); | ||
| let _ = std::fs::remove_dir(&link); | ||
| let _ = std::fs::remove_dir_all(&base); | ||
|
|
||
| assert_eq!( | ||
| result, | ||
| PathCanonical::Unknown, | ||
| "a deny under a dangling-junction ancestor must fail closed, not \ | ||
| resolve to a Canonical path still containing the junction" | ||
| ); | ||
| } | ||
|
|
||
| #[cfg(not(windows))] | ||
| #[test] | ||
| fn non_windows_is_unknown() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rationale comment on the new equality guard credits the wrong layer, which makes a load-bearing invariant easy to break.
Two mismatches with the code:
NormalizedPath::parse(mounted)againstNormalizedPath::parse(denied), i.e. lexically normalized forms. That distinction is doing all the work here.normalize_filesystem_paths(config_parser.rs:378-412) collapses viaHashSet<String>+denied.contains(p), which is byte-identical string equality. It does not fold case, separators, or... SoC:\Projectvsc:/projectsails straight through parse time; what actually accepts it isNormalizedPath::parseright here (policy_mapping.rs:138-174—to_lowercase(),split(['/', '\\']), and./..folding).Worth noting the pre-existing comment on
contains_strictlyjust above (:185-191) states this correctly and narrowly:That's accurate —
normalize_filesystem_pathsgenuinely does handle the same-string case. The new comment reuses that same parenthetical but widens the claim to "literal/case/separator/..-variant", which is the part that isn't true of that layer. So this is a small accuracy regression against a neighbouring comment that already had it right.Why it matters beyond wording. The behavior is correct today; the risk is the next editor. Someone reading "original strings differ lexically" could reasonably tighten this to the literal reading
which would then reject
C:\Project(mount) vsc:/project(deny) as alias-induced overlap — turning an enforceable exact-match config into a hard validation failure. Someone reading "collapsed at parse time" could equally conclude theNormalizedPath::parsecalls here are redundant and drop them, with the same outcome. Neither change would fail a test today (see below).Suggested rewrite:
One edge case the imprecise wording hides, worth confirming is intended: the comment implies every "literal variant" is accepted, but
parsedoesn't fold the extended-length prefix.\\?\C:\projectyields{ drive: None, rooted: true, components: ["?", "c:", "project"] }whileC:\projectyields{ drive: Some("c:"), rooted: true, components: ["project"] }— lexically different. Sincecanonicalize_pathdoes collapse\\?\prefixes (filesystem_canonical.rs:24-26), a deny written as\\?\C:\projectagainst a mount ofC:\projectis canonical-equal + lexically-different, so it gets rejected as an alias — even though it's a pure spelling variant with no on-disk indirection. Failing closed is the safe direction, so this isn't urgent, but the user-facing "cannot be enforced" error would be misleading. Either fold the prefix inparseor narrow the comment's claimed accept-set.Finally, the accept side isn't pinned by any test — the only acceptance case uses byte-identical strings, so the normalization fold this comment describes is entirely untested. Worth adding
C:\Projectvsc:/projectandC:\projectvsC:\x\..\project, both with canonical-equal resolver results, asserting the config is accepted.