Skip to content

fix: recognize host-level UNC roots in path containment checks - #1327

Draft
crowecawcaw wants to merge 1 commit into
aws-deadline:mainlinefrom
crowecawcaw:fix/unc-host-path-containment
Draft

fix: recognize host-level UNC roots in path containment checks#1327
crowecawcaw wants to merge 1 commit into
aws-deadline:mainlinefrom
crowecawcaw:fix/unc-host-path-containment

Conversation

@crowecawcaw

Copy link
Copy Markdown
Contributor

Fixes #1321

Problem

os.path.commonpath cannot compare a Windows host-level UNC path (\\server) with a path under one of its shares. ntpath.splitdrive reports no drive for the former and \\server\share for the latter, so it raises. Three distinct pairs fail:

Pair commonpath result
\\host vs \\host\share\f ValueError: Paths don't have the same drive (the reported case)
\\host\s1\a vs \\host\s2\b ValueError: Paths don't have the same drive
\\host\share vs \\host\share\f ValueError: Can't mix absolute and relative paths

_is_known_path caught that ValueError as "not contained", so every asset path under a \\server known root was reported as unknown and submissions from a network share could not proceed without confirmation.

The audit found the same root cause at three more sites, where the exception was not caught at all and surfaced as a raw ValueError rather than a verdict — two of them latent crashes rather than merely wrong answers:

Site Before
_submit_job_bundle.py:_is_known_path caught ValueError → valid paths silently reported unknown
_submit_job_bundle.py:_filter_redundant_known_paths Path.parts collapses \\server\share into one atom, so a host root never subsumed its shares
job_bundle/loader.py symlink containment uncaught → crash for a bundle at a share root, or a symlink escaping onto a share
job_bundle/parameters.py PATH default uncaught → same
cli/_groups/job_group.py download summary stray trailing separator in user-visible text

Approach

New private deadline/client/_path_utils.py compares paths component by component, so a UNC host is an ordinary ancestor of its shares.

Path spaces are discriminated with splitroot (backported for Python 3.9–3.11) rather than inferred from the string. That keeps a rooted driveless path, a drive root, a drive-relative path, the UNC namespace, and the device namespace from ever being confused for one another — the first draft inferred this from leading empty strings and had three fail-open defects as a result. Extended-length prefixes fold to the plain form they denote (\\?\C:\C:\); those with no plain spelling (Volume{GUID}, \\.\) keep their prefix and their own space.

commonpath and commonprefix are now banned via ruff TID251, so the bug class cannot return silently.

Hardening this exposed

Known-asset-path handling had a fail-open worth calling out on its own: roots were compared raw against absolute candidates. Making them absolute naively meant os.path.abspath("") — the entire working directory — became a trusted root, so every file beside the submitting shell was treated as known. Because a suppressed warning is what lets a non-interactive submit proceed, those files would upload silently.

Roots are now ~-expanded and dropped unless absolute. An empty root is reachable three ways: --known-asset-path "", the MCP tool's unvalidated JSON array, and a PATH/FILE parameter whose allowedValues suppressed absolutization (leaving dirname(value) empty). Failing closed costs only a warning.

Testing

Windows semantics run through an explicit path_module, so they execute on every platform. This matters: UNC paths cannot be constructed with os.path.join(os.sep, ...), so tests written against the native module silently skipped every UNC case on Linux and macOS — which is how the original bug shipped.

Beyond the case tables:

  • pathlib as an independent oracle — containment is diffed against PurePath.is_relative_to on 3.12+, where the only permitted disagreement is the UNC-ancestor case this PR adds. Mutation-tested: reintroducing either of two earlier fail-opens turns it red.
  • Invariants — reflexivity, "common_ancestor's result contains all its inputs", and transitivity, over a corpus spanning every path space.
  • splitroot backport — differential-tested against the stdlib over 181k generated cases (0 mismatches), with a negative control confirming the harness detects divergence.
  • Real SMB (test/integ/windows_smb) — every other test models Windows lexically, which cannot confirm the SMB redirector agrees. This creates a loopback share on a Windows runner and checks that a host-level root contains files on its shares, realpath output stays contained, a bundle at a share root validates, an escaping symlink is still rejected, and a mapped drive stays a distinct path space. Excluded from default test paths and dispatched manually, since creating a share needs administrator rights.

hatch run fmt / lint / test clean: 3322 passed, 100% coverage on the new module. The two deadline_mcp collection errors are pre-existing (the optional mcp extra is not installed) and reproduce on mainline.

Reviewer notes

  • The \\server-contains-its-shares rule is the one piece of hand-written path logic left. No stdlib helper models it, because Windows does not treat \\server as a mountable directory. It is the highest-value thing to attack.
  • A pathlib-based rewrite was prototyped and rejected: PureWindowsPath parses \\srv as drive-less before Python 3.12, so it would have un-fixed this issue on 3.9–3.11 (verified on real 3.9/3.11 interpreters) and introduced cross-namespace aliasing.
  • The identical bug exists in deadline-cloud-job-attachments (_utils._is_relative_to, _normalize_windows_path, and three commonpath sites, one guarding a TOCTOU symlink check). Deliberately out of scope here. Note those must be fixed together: correcting _is_relative_to alone regroups inputs under \\host and triggers the commonpath crash that the containment bug currently masks.
  • Real SMB is validated only for loopback on a single host — not DFS referrals, cross-machine auth, or the reporter's IP-literal NAS (\\192.168.20.20).

@github-actions github-actions Bot added the waiting-on-maintainers Waiting on the maintainers to review. label Aug 7, 2026
pytest.skip("no free drive letter to map the share onto")

try:
asset = Path(drive + "\\") / "mapped_probe.txt"
leading = len(rest) - len(rest.lstrip(separators))
# POSIX gives '//' its own root spelling, but collapses three or more.
root_length = 2 if (leading == 2 and path_module.sep == "/") else 1
return drive, rest[:root_length], rest[root_length:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Python 3.9–3.11 (ntpath.splitroot was added in 3.12, and requires-python is >=3.9), this backport diverges from the real splitroot for a share-less UNC host like \\server — the exact root form in issue #1321 (\\192.168.20.20).

ntpath.splitdrive("\\\\server") returns ("", "\\\\server") (no share ⇒ empty drive), so here drive="" and the backport produces root="\\" (a single backslash). _split_anchored then falls through to drive + root and yields anchor \ + ["server"].

The real 3.12 splitroot("\\\\server") instead returns ("\\\\server", "", "") — the host in the drive field — so _split_anchored takes the drive.startswith(_UNC_ANCHOR) branch and yields the UNC anchor \\ + ["server"].

A candidate such as \\server\share\file always has a share, so splitdrive puts \\server\share in the drive on every version, giving anchor \\. Comparing components then fails on 3.9–3.11:

candidate: ["\\\\", "server", "share", "file"]   # UNC anchor "\\"
root:      ["\\",   "server"]                     # single "\"  ← mismatch

So is_path_contained(r"\\server\share\file", r"\\server") returns False on 3.9–3.11, meaning the #1321 fix does not work there and the corresponding unit tests (e.g. (r"\\host\share\file", r"\\host", True)) would fail on those interpreters. Consider having the backport detect a share-less \\host and route the host into the drive field (or normalize the anchor) so it matches splitroot.

@crowecawcaw
crowecawcaw force-pushed the fix/unc-host-path-containment branch from 666fe0c to a1c2c95 Compare August 11, 2026 21:46
os.path.commonpath cannot compare a Windows host-level UNC path (\\server) with
a path under one of its shares: ntpath.splitdrive reports no drive for the former
and \\server\share for the latter, so it raises "Paths don't have the same
drive". It raises the same way for two shares of one host, and for a share-root
directory compared with its own files ("Can't mix absolute and relative paths").

_is_known_path caught that ValueError as "not contained", so every asset path
under a \\server known root was reported as unknown and submissions from a
Windows network share could not proceed without confirmation. Three other
containment checks did not catch it at all and surfaced a raw ValueError instead
of a verdict: job bundle symlink containment (for a bundle at a share root, or a
symlink escaping a drive-letter bundle onto a share), PATH parameter default
containment, and the download summary.

Add deadline.client._path_utils, which compares paths component by component so a
UNC host is an ordinary ancestor of its shares. Path spaces are discriminated with
splitroot (backported for Python < 3.12) rather than inferred from the string, so
a rooted driveless path, a drive root, a drive-relative path, the UNC namespace,
and the device namespace can never be confused for one another. Route all four
containment checks and the summary through it, and ban commonpath/commonprefix via
ruff TID251 so the bug class cannot return.

Containment fails closed on everything it cannot resolve, since every caller uses
it to decide whether a path is trusted. Extended-length and device paths
(\\?\..., \\.\...) keep their prefix and occupy a path space of their own rather
than being folded into the plain form they denote, so they never alias it; those
prefixes disable path normalization, so that space has no share-relative form and
a root there still contains its own files. The bare \\ anchor is not a root
directory the way POSIX / is -- splitroot reads it as a drive with an empty root,
an incomplete UNC spelling naming no server -- so it is an ancestor of nothing.
Treating it as one would trust every reachable share from a single root, and
ntpath.isabs reports it absolute, so it survives a caller's isabs filter.

Also harden the known-asset-path handling this exposed. Roots are expanded for
'~' and dropped unless absolute, rather than kept and compared or resolved against
the working directory. A non-absolute root cannot match the absolute candidates it
is checked against, so it is a hazard only once a caller normalizes it: resolving
one would mark an unrelated tree as trusted (os.path.abspath("") is the whole
working directory), suppressing the unknown-path warning and letting a
non-interactive submit upload files the user never designated. Dropping it at the
boundary keeps that from depending on which normalization a future caller reaches
for, and costs only a warning. An empty root arrives from --known-asset-path "",
the MCP tool's unvalidated JSON array, and a PATH parameter whose allowedValues
suppressed absolutization. Redundancy filtering now compares components, so a UNC
host subsumes its shares (Path.parts collapses \\server\share into one atom), case
variants of one location dedupe on Windows, and the caller's first,
highest-precedence spelling is the one retained.

Windows semantics are tested through an explicit path_module so they run on every
platform; UNC paths cannot be built with os.path.join(os.sep, ...), so tests
written against the native module silently skipped them on POSIX. Containment is
additionally checked against pathlib.PurePath.is_relative_to as an independent
oracle on Python 3.12+, where the only permitted disagreement is the UNC-ancestor
case this change adds. Reflexivity, ancestor soundness, and transitivity are
asserted over a corpus spanning every path space.

test/integ/windows_smb validates the fix against a real SMB share on a Windows
runner, since every other test models Windows lexically and cannot confirm the
redirector agrees. It is excluded from the default test paths and dispatched
manually, because creating a share requires administrator rights.

Fixes aws-deadline#1321

Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
@crowecawcaw
crowecawcaw force-pushed the fix/unc-host-path-containment branch from a1c2c95 to e56265f Compare August 11, 2026 22:01
expanded = (os.path.expanduser(path) for path in known_asset_paths if path)
# normpath, not abspath: dedupes equivalent spellings without consulting the cwd.
ordered = list(
dict.fromkeys(os.path.normpath(path) for path in expanded if os.path.isabs(path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare \\ in the known-asset-paths list survives this isabs filter and then poisons the TRIE, silently dropping every UNC known path.

ntpath.isabs("\\\\") is True (it only inspects the first three characters for a leading separator) — the _path_utils code even notes this at _path_utils.py:140. So "\\\\" passes os.path.isabs, os.path.normpath leaves it unchanged, and path_components yields the single component ["\\\\"] (the _UNC_ANCHOR).

Because it has one component it sorts first, and the loop marks dir_tree["\\\\"] = True. Every subsequent UNC root then walks parts[:-1] == ["\\\\", "server", ...], hits current.get("\\\\") is True on the first part, and is filtered out as redundant. So _filter_redundant_known_paths given [r"\\", r"\\server\share", r"\\other\share"] returns only the bare anchor — both real roots are dropped.

That contradicts is_path_contained, which deliberately special-cases the bare anchor so it contains nothing (if root_components == [_UNC_ANCHOR]: return candidate_components == [_UNC_ANCHOR]).

Net effect: with a stray \\ entry the returned list holds a root that matches no candidate, and the genuine UNC roots that would have matched are gone — so files under them get reported as unknown locations, producing spurious warnings and blocking non-interactive submits. A bare \\ reaches here from the same inputs the docstring already calls out for the empty-string case: settings.known_asset_paths split on os.pathsep, --known-asset-path, and MCP input.

Since the two functions disagree about what \\ means, it seems cleanest to drop it where empty/relative roots are already dropped — e.g. also exclude roots whose path_components(...) is exactly the bare UNC anchor.

#
# Not part of Code Quality: creating a share needs administrator rights, and the loopback
# share is slower and more environment-dependent than a unit test.
on:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow has no automatic trigger, so the "regression coverage for issue #1321" it provides never actually runs.

on: declares only workflow_dispatch and workflow_call, and nothing in the repo calls it — the only uses: ./.github/workflows/... references are code_quality.yml and the three dcm_integration_test_* files (from manual_pypi_release.yml, release_publish.yml, dcm_integration_tests.yml). The tag input and the workflow_call block suggest a caller was intended.

Since test/integ is outside testpaths and outside hatch run test, test/integ/windows_smb is not reached by any other job either, so today the only way these tests execute is a manual dispatch. If the intent is for the SMB check to guard the fix, it needs to be wired into a scheduled or release-time workflow (e.g. alongside the dcm_integration_test_windows call in release_publish.yml) — otherwise a future regression in _path_utils UNC handling ships uncaught by anything but the lexical ntpath unit tests, which by their own docstring "say nothing about SMB".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on-maintainers Waiting on the maintainers to review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] _is_known_path fails path validation for host-level UNC roots (e.g. \\<host> )

2 participants