[WSLC] Reject nested deniedPaths overlaps in WSLC policy mapping#650
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR adds WSLC-specific validation to reject configs where a deniedPaths entry is nested under a mounted readwritePaths/readonlyPaths parent, since WSLC’s flat volume-mount model cannot mask subtrees the way LXC/Bubblewrap can.
Changes:
- Add a
validate_denied_path_overlappre-check in WSLC policy mapping to reject unenforceable nested denies. - Invoke the new validation during WSLC runner setup (after object-identity normalization and delegation checks).
- Add unit tests covering overlap detection across path casing/separator variants and list combinations.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/backends/wslc/common/src/wsl_container_runner.rs | Calls the new denied-path overlap validator during WSLC request setup to fail fast on unenforceable configs. |
| src/backends/wslc/common/src/policy_mapping.rs | Implements denied-path overlap validation logic and adds targeted unit tests for the new behavior. |
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
| /// check and fails closed on unresolvable paths when `deniedPaths` are present. | ||
| /// Treat this function as defense-in-depth for the flat-mount overlay gap, not | ||
| /// a complete traversal-safe deny on its own. | ||
| pub fn validate_denied_path_overlap( |
There was a problem hiding this comment.
the compare is textual, not path-aware
THE BUG
normalize_path_components lowercases + splits on / and ; is_strict_descendant does a
component-wise prefix compare. Nothing is canonicalized, so spellings that resolve into
a mounted tree pass the check:
-
..traversal (main one). Mount C:\project\sub.. (resolves to C:\project) while
denying C:\project\secret. Textually mount = ["c:","project","sub",".."], not a prefix
of ["c:","project","secret"] -> accepted, but secret is exposed. Same for denying
C:\outside..\project\secrets against a C:\project mount. -
Bare root mount (line 197). \ or / normalizes to an empty vec, so
if mounted_components.is_empty() { continue; }skips that mount entirely; anything
denied under it is accepted. (C:\ -> ["c:"] is fine/tested; only the drive-less root
spelling leaks.) -
Drive-relative (line 127). C:secrets splits into one fused token ["c:secrets"], which
doesn't prefix-match a C:\ mount ["c:"] -> escapes. -
Aliases: \?\ prefixes, 8.3 short names, junctions/symlinks, trailing dots/spaces all
differ textually but resolve into the mounted tree.
D6 does NOT cover these: normalize_object_conflicts only reconciles paths resolving to the
SAME object and only fails closed on UNRESOLVABLE paths. A mount vs a denied child are
different (parent/child) objects, and a resolvable .. spelling isn't unresolvable - so
neither layer catches it. The doc-comment claiming D6 handles this is wrong and should be
corrected.
Bonus false-positive (Medium): stripping leading separators makes \project\x (absolute)
and project\x (relative) identical, so a valid config can be wrongly rejected.
THE FIX (one change closes cases 1-3 + the false positive)
-
Parse structurally instead of splitting strings: std::path::Path::components() on
Windows (or typed-path for cross-build parsing) so Prefix and RootDir stay distinct -
fixes C:secrets vs C:\ and \ vs project. -
Lexically fold . and .. on BOTH the mounted and denied sides before comparing, so
C:\project\sub.. becomes C:\project. Pure lexical, no disk access. -
Where paths exist, canonicalize (std::fs::canonicalize / GetFinalPathNameByHandle) to
resolve 8.3/junctions/symlinks. If deniedPaths are present and a path can't be
resolved unambiguously, FAIL CLOSED rather than falling back to the textual compare. -
Use the same canonical form for the actual SDK mount, so you don't validate one path
and mount another. -
Drop the empty-components
continue: after structural parsing a root mount is an
ancestor of everything on that drive - treat it as such (or reject when denies present).
TESTS TO ADD
..on the denied side and on the mounted side -> rejected.- bare \ / root mount with a nested denied path -> rejected.
- drive-relative C:secrets under C:\ -> rejected.
- absolute \project\x vs relative project\x -> not a false rejection.
- integration test at the runner call site (wsl_container_runner.rs:899): runner returns
the overlap error and starts no container. The 11 unit tests only cover the pure
function; a wiring/ordering regression would pass them all.
There was a problem hiding this comment.
Thanks for the catch!
I am fixing the lexical/structural half in this branch, will do the full alias canonicalization in a follow-up right after, hope that is alright?
📖 Description
WSLC mounts host paths as flat WSL volumes and has no overlay primitive, so a deniedPaths entry nested under a mounted
(readwrite/readonly) parent cannot be masked and would remain accessible through the parent mount. Add validate_denied_path_overlap, which rejects such configs at policy-mapping time (component-wise, case- and separator-insensitive, drive-letter aware) instead of silently leaving the subtree exposed.
🔗 References
🔍 Validation
✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes (see docs/pull-requests.md)📋 Issue Type
GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See docs/pull-requests.md.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See docs/pull-requests.md
for the steps.
Microsoft Reviewers: Open in CodeFlow