Files
kernalix7--winpodx/tests/test_reverse_open_paths.py
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

616 lines
22 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for ``winpodx.reverse_open.paths``.
Phase 1 PR 1 (issue #48): UNC -> POSIX translation, strict subtree
check, and TOCTOU-safe spawn helper.
The translation function is the trust boundary between guest-supplied
input and host-side execution; every input either returns a valid
Path under a share root or raises ``ReversePathError``. These tests
exercise both the happy paths and the security-relevant rejections.
The atomic ``safe_open_unc`` context manager replaces the older
translate-then-open_for_spawn split. It validates against
``/proc/self/fd/N`` *after* the FD is acquired, so an attacker cannot
swap a symlink between validation and open. The three swap-attack
tests below exercise that property end-to-end.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from winpodx.reverse_open.paths import (
ReversePathError,
SafeFile,
is_relative_to,
safe_open_unc,
translate_unc_to_posix,
)
# ---- translate_unc_to_posix: happy paths --------------------------
def test_happy_path_home(tmp_path):
"""``\\tsclient\\home\\foo`` resolves under the home share root."""
target = tmp_path / "foo"
target.touch()
share_roots = {"home": tmp_path}
result = translate_unc_to_posix(r"\\tsclient\home\foo", share_roots)
assert result == (tmp_path / "foo").resolve()
def test_happy_path_media_nested(tmp_path):
"""``\\tsclient\\media\\USB\\x`` resolves under media share root."""
media_root = tmp_path / "media"
media_root.mkdir()
(media_root / "USB").mkdir()
(media_root / "USB" / "x").touch()
share_roots = {"media": media_root}
result = translate_unc_to_posix(r"\\tsclient\media\USB\x", share_roots)
assert result == (media_root / "USB" / "x").resolve()
def test_happy_path_custom_drive(tmp_path):
"""A user-configured ``/drive:work,/path`` resolves correctly."""
work_root = tmp_path / "work"
work_root.mkdir()
(work_root / "report.pdf").touch()
share_roots = {"work": work_root}
result = translate_unc_to_posix(r"\\tsclient\work\report.pdf", share_roots)
assert result == (work_root / "report.pdf").resolve()
def test_happy_path_unicode(tmp_path):
"""Unicode (Hangul) path components survive translation."""
sub = tmp_path / "한글"
sub.mkdir()
(sub / "notes.txt").touch()
share_roots = {"home": tmp_path}
result = translate_unc_to_posix(r"\\tsclient\home\한글\notes.txt", share_roots)
assert result == (sub / "notes.txt").resolve()
def test_case_insensitive_prefix_and_share(tmp_path):
"""``\\TSCLIENT\\HOME\\foo`` matches ``home`` share case-insensitively."""
(tmp_path / "foo").touch()
share_roots = {"home": tmp_path}
result = translate_unc_to_posix(r"\\TSCLIENT\HOME\foo", share_roots)
assert result == (tmp_path / "foo").resolve()
def test_mixed_case_share_name(tmp_path):
"""``Home`` (mixed case) still matches ``home`` share."""
(tmp_path / "foo").touch()
share_roots = {"home": tmp_path}
result = translate_unc_to_posix(r"\\tsclient\Home\foo", share_roots)
assert result == (tmp_path / "foo").resolve()
def test_nonexistent_target_still_resolves(tmp_path):
"""``strict=False`` resolution: missing leaf still translates.
The listener checks existence separately with a friendlier error,
so the translator only validates path *shape*, not file presence.
"""
share_roots = {"home": tmp_path}
result = translate_unc_to_posix(r"\\tsclient\home\does_not_exist.xml", share_roots)
assert result == (tmp_path / "does_not_exist.xml").resolve()
# ---- translate_unc_to_posix: traversal & symlink escape -----------
def test_traversal_escapes_share_root(tmp_path):
"""``..`` walking out of the share root is rejected post-resolve."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="escapes share root"):
translate_unc_to_posix(r"\\tsclient\home\..\..\etc\passwd", share_roots)
def test_symlink_escape_rejected(tmp_path):
"""A symlink in the share that points outside the root is rejected."""
share_roots = {"home": tmp_path}
escape_link = tmp_path / "escape"
escape_link.symlink_to("/etc")
with pytest.raises(ReversePathError, match="escapes share root"):
translate_unc_to_posix(r"\\tsclient\home\escape\passwd", share_roots)
def test_proc_denylist_post_resolve():
"""A share root pointing at /proc is caught by the denylist.
Artificial -- the live cfg doesn't allow ``/drive:proc,/proc`` --
but defence-in-depth: even if some future code path admitted such
a config, the post-resolve denylist still rejects.
"""
share_roots = {"proc": Path("/proc")}
with pytest.raises(ReversePathError, match="system denylist root"):
translate_unc_to_posix(r"\\tsclient\proc\self\status", share_roots)
# ---- translate_unc_to_posix: input validation ---------------------
def test_empty_input(tmp_path):
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="empty"):
translate_unc_to_posix("", share_roots)
def test_nul_byte_input(tmp_path):
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="NUL"):
translate_unc_to_posix("\\\\tsclient\\home\\foo\0bar", share_roots)
def test_non_string_input(tmp_path):
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="must be str"):
translate_unc_to_posix(b"\\\\tsclient\\home\\foo", share_roots) # type: ignore[arg-type]
def test_non_string_none(tmp_path):
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="must be str"):
translate_unc_to_posix(None, share_roots) # type: ignore[arg-type]
def test_bare_share_root_no_path(tmp_path):
"""``\\tsclient\\home`` with no trailing path is rejected."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="bare share root"):
translate_unc_to_posix(r"\\tsclient\home", share_roots)
def test_bare_share_root_trailing_separator(tmp_path):
"""``\\tsclient\\home\\`` with empty rest is rejected."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="bare share root"):
translate_unc_to_posix("\\\\tsclient\\home\\", share_roots)
def test_bare_prefix_only(tmp_path):
"""``\\tsclient\\`` (no share name at all) is rejected."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="bare share root"):
translate_unc_to_posix("\\\\tsclient\\", share_roots)
def test_forward_slash_variant_rejected(tmp_path):
"""``//tsclient/home/foo`` is NOT a valid UNC prefix -- rejected.
Windows hands the shim backslash-form UNCs verbatim. Forward
slashes would only appear in hand-crafted requests, which we
treat as malicious.
"""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="not under"):
translate_unc_to_posix("//tsclient/home/foo", share_roots)
def test_unknown_share_name_lists_known(tmp_path):
"""Unknown share name error message includes the known shares."""
share_roots = {"home": tmp_path, "media": tmp_path}
with pytest.raises(ReversePathError) as excinfo:
translate_unc_to_posix(r"\\tsclient\bogus\x", share_roots)
msg = str(excinfo.value)
assert "bogus" in msg
assert "home" in msg
assert "media" in msg
def test_not_under_tsclient_prefix(tmp_path):
"""Random non-UNC string rejected with a 'not under' message."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="not under"):
translate_unc_to_posix("C:\\Users\\foo\\file.txt", share_roots)
def test_empty_share_roots_rejected():
"""An empty share table refuses every path -- no legal target exists."""
with pytest.raises(ReversePathError, match="no share roots configured"):
translate_unc_to_posix(r"\\tsclient\home\foo", {})
def test_non_ascii_share_name_rejected(tmp_path):
"""Non-ASCII share name is treated as malicious / misconfigured."""
share_roots = {"home": tmp_path}
with pytest.raises(ReversePathError, match="non-ASCII share name"):
translate_unc_to_posix("\\\\tsclient\\한글\\foo", share_roots)
# ---- is_relative_to ----------------------------------------------
def test_is_relative_to_subtree():
assert is_relative_to(Path("/a/b/c"), Path("/a/b"))
def test_is_relative_to_equal():
assert is_relative_to(Path("/a/b"), Path("/a/b"))
def test_is_relative_to_parent_of_root_is_false():
"""Parent of root must NOT be considered "under" root.
This is the strict-semantics property the security check relies
on. Prior to 3.12, ``Path.is_relative_to`` always returned False
for parent-of-root; 3.12 added ``walk_up`` semantics that we
explicitly opt out of.
"""
assert not is_relative_to(Path("/a"), Path("/a/b"))
def test_is_relative_to_sibling_is_false():
assert not is_relative_to(Path("/a/c"), Path("/a/b"))
def test_is_relative_to_unrelated_is_false():
assert not is_relative_to(Path("/x/y"), Path("/a/b"))
# ---- safe_open_unc / SafeFile -----------------------------------
def _fd_count() -> int:
"""Count currently open FDs in this process via /proc/self/fd."""
return len(os.listdir("/proc/self/fd"))
def test_safe_open_unc_yields_safefile_with_proc_path(tmp_path):
"""``safe_open_unc`` yields a SafeFile whose ``proc_path`` is
``/proc/self/fd/N`` and whose fd is closed after context exit.
"""
target = tmp_path / "f"
target.write_text("hello")
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\f", share_roots) as safe:
assert isinstance(safe, SafeFile)
assert safe.fd >= 0
assert safe.proc_path == Path("/proc/self/fd") / str(safe.fd)
# While inside the context the proc_path should resolve to a
# readable inode -- the kernel keeps the inode pinned via fd.
assert safe.proc_path.exists()
captured_fd = safe.fd
# After context exit the fd is closed; closing again is a no-op
# but os.fstat on the original fd should now fail with EBADF.
with pytest.raises(OSError):
os.fstat(captured_fd)
def test_safe_open_unc_nonexistent_raises_oserror(tmp_path):
"""Opening a path that doesn't exist surfaces OSError.
A missing target raises ``ReversePathError`` (not a raw
``FileNotFoundError``) so the listener's ``except ReversePathError`` drops
the request instead of letting the error escape ``_handle_request`` and
re-loop on the stale entry forever (#425).
"""
share_roots = {"home": tmp_path}
before = _fd_count()
with pytest.raises(ReversePathError):
with safe_open_unc(r"\\tsclient\home\does_not_exist", share_roots):
pass
# The open failed, so the FD was never allocated -- count is unchanged.
assert _fd_count() == before
def test_safe_open_unc_safefile_close_idempotent(tmp_path):
"""``SafeFile.close()`` swallows EBADF from a double-close."""
target = tmp_path / "f"
target.touch()
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\f", share_roots) as safe:
safe.close()
# A second close inside or after the context must not raise.
safe.close()
def test_safe_open_unc_closes_fd_on_inner_exception(tmp_path):
"""An exception raised inside the with-block still closes the fd."""
target = tmp_path / "f"
target.touch()
share_roots = {"home": tmp_path}
captured: dict[str, int] = {}
class _Sentinel(Exception):
pass
with pytest.raises(_Sentinel):
with safe_open_unc(r"\\tsclient\home\f", share_roots) as safe:
captured["fd"] = safe.fd
raise _Sentinel("boom")
with pytest.raises(OSError):
os.fstat(captured["fd"])
def test_safe_open_unc_rejects_traversal(tmp_path):
"""``..`` traversal is rejected by the readlink-based check.
Set up: a sibling directory next to ``tmp_path`` containing a
real file, so the candidate path *opens successfully* -- the
failure must come from the readlink-vs-share-root check, not
from a missing target.
"""
sibling = tmp_path.parent / (tmp_path.name + "_sibling")
sibling.mkdir(exist_ok=True)
victim = sibling / "victim"
victim.write_text("secret")
share_roots = {"home": tmp_path}
before = _fd_count()
with pytest.raises(ReversePathError, match="escapes share root"):
with safe_open_unc(
f"\\\\tsclient\\home\\..\\{sibling.name}\\victim",
share_roots,
):
pass
assert _fd_count() == before
def test_safe_open_unc_rejects_symlink_leaf(tmp_path):
"""A symlink leaf is rejected, regardless of where it points.
``O_PATH | O_NOFOLLOW`` (unlike plain ``O_NOFOLLOW``) does NOT
raise ELOOP for a symlink leaf -- it opens the symlink itself,
so ``readlink('/proc/self/fd/N')`` would return the symlink's
own path, which is inside the share root by construction. The
extra ``S_ISLNK`` check on the resulting FD is what catches the
case and raises ``ReversePathError``.
"""
share_roots = {"home": tmp_path}
escape_link = tmp_path / "escape"
escape_link.symlink_to("/etc/passwd")
before = _fd_count()
with pytest.raises(ReversePathError, match="symlink leaf"):
with safe_open_unc(r"\\tsclient\home\escape", share_roots):
pass
assert _fd_count() == before
def test_safe_open_unc_rejects_symlink_leaf_pointing_inside(tmp_path):
"""Even a symlink whose target is inside the share is rejected.
Defence-in-depth: a symlink leaf is always refused, so a guest
can't use a same-share symlink as a stepping stone for a later
swap attack.
"""
share_roots = {"home": tmp_path}
real = tmp_path / "real"
real.write_text("ok")
inside_link = tmp_path / "inside_link"
inside_link.symlink_to(real)
before = _fd_count()
with pytest.raises(ReversePathError, match="symlink leaf"):
with safe_open_unc(r"\\tsclient\home\inside_link", share_roots):
pass
assert _fd_count() == before
def test_safe_open_unc_empty_share_roots(tmp_path):
"""Empty share-roots dict refuses every path."""
with pytest.raises(ReversePathError, match="no share roots configured"):
with safe_open_unc(r"\\tsclient\home\foo", {}):
pass
def test_safe_popen_kwargs_returns_empty(tmp_path):
"""``SafeFile.popen_kwargs()`` returns an empty dict.
The listener now hands ``real_path`` (the kernel's canonical path)
to the spawned child instead of the FD-bound ``proc_path``, so no
FD inheritance is needed. See SafeFile docstring for why we made
this trade (Firefox / LibreOffice D-Bus handoff workaround).
"""
target = tmp_path / "f"
target.touch()
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\f", share_roots) as safe:
assert safe.popen_kwargs() == {}
def test_safe_open_unc_exposes_real_path(tmp_path):
"""SafeFile.real_path is the kernel's canonical post-resolve path.
This is what the listener hands to the child process — opaque,
no FD inheritance required, works for D-Bus-handoff apps like
Firefox where /proc/self/fd/N would fail in the receiver process.
"""
target = tmp_path / "f"
target.write_text("hello")
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\f", share_roots) as safe:
# real_path resolves to the validated inode and contains the
# same bytes we'd see via the FD-bound proc_path.
assert safe.real_path.is_file()
assert safe.real_path.read_text() == "hello"
# It's also distinct from /proc/self/fd/N — the two are kept
# as separate fields for callers with different needs.
assert "/proc/self/fd/" not in str(safe.real_path)
# ---- Symlink-swap-during-window attack tests ---------------------
#
# These three tests exercise the property the new atomic flow is
# designed to guarantee: a guest cannot redirect the spawn target by
# swapping a symlink at any point between validation and use, because
# validation is performed on the kernel's authoritative readlink of
# the already-acquired FD, not on a string we re-resolve later.
def test_swap_attack_leaf_symlink_after_open_targets_original_inode(tmp_path):
"""Validation succeeds, attacker swaps the leaf symlink, the FD
still points at the original inode.
Setup: ``link`` is a regular file (not a symlink, so ``O_NOFOLLOW``
accepts it). Inside the context we ``unlink`` it and recreate it
as a symlink to ``decoy``. The kernel's view through
``/proc/self/fd/N`` continues to be the original inode -- proven
by reading via ``proc_path`` and getting the original bytes.
"""
real_target = tmp_path / "link"
real_target.write_text("original")
decoy = tmp_path / "decoy"
decoy.write_text("attacker")
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\link", share_roots) as safe:
# Attacker rewrites the on-disk path to a symlink pointing
# at the decoy.
real_target.unlink()
real_target.symlink_to(decoy)
# The kernel's FD-backed view still points at the inode we
# validated -- reading via /proc/self/fd/N yields the
# original content, not the decoy.
with safe.proc_path.open("rb") as f:
data = f.read()
assert data == b"original"
def test_swap_attack_non_leaf_component_after_open_keeps_inode(tmp_path):
"""Validation succeeds, attacker swaps a non-leaf directory
component, the FD still points at the original inode.
Setup: ``inner/leaf`` exists. After we open it, the attacker
renames ``inner`` aside and replaces it with a directory
containing a different ``leaf``. The on-disk path now resolves
to a different file, but the FD we hold pins the *original*
inode -- proven by reading the original bytes via ``proc_path``.
"""
inner = tmp_path / "inner"
inner.mkdir()
leaf = inner / "leaf"
leaf.write_text("original")
decoy_inner = tmp_path / "decoy_inner"
decoy_inner.mkdir()
(decoy_inner / "leaf").write_text("attacker")
share_roots = {"home": tmp_path}
with safe_open_unc(r"\\tsclient\home\inner\leaf", share_roots) as safe:
# Attacker swaps the parent directory under us.
inner.rename(tmp_path / "inner_old")
decoy_inner.rename(inner)
# On-disk resolution now hits the attacker's file:
assert (tmp_path / "inner" / "leaf").read_text() == "attacker"
# But the FD we hold still points at the original inode.
with safe.proc_path.open("rb") as f:
data = f.read()
assert data == b"original"
def test_swap_attack_invalid_path_does_not_leak_fd(tmp_path):
"""A path that fails validation inside ``safe_open_unc`` must
never leak an FD.
Counts ``/proc/self/fd`` entries before and after the attempted
open: the count must be unchanged whether the failure was a
parse error (no os.open call) or a readlink-validation rejection
(FD acquired then closed in the except block).
"""
# Set up: a symlink whose target is OUTSIDE the share root.
# Using a regular file at a target outside tmp_path would also
# work, but the cleanest case is a real file inside a directory
# that the open() succeeds on, then validation rejects via the
# readlink check.
outside = tmp_path.parent / "outside_share"
outside.mkdir(exist_ok=True)
target_outside = outside / "victim"
target_outside.write_text("secret")
# Build a share root that's a directory containing a symlink to
# a directory outside the share.
share = tmp_path / "share"
share.mkdir()
# Bind-mount-like path: an inner directory that's a symlink to
# somewhere outside. O_NOFOLLOW only blocks the *leaf*; here the
# leaf is "victim" (a real file), and "escape" is a non-leaf
# symlink, which os.open() will follow even with O_NOFOLLOW.
(share / "escape").symlink_to(outside)
share_roots = {"home": share}
before = _fd_count()
with pytest.raises(ReversePathError):
with safe_open_unc(r"\\tsclient\home\escape\victim", share_roots):
pass
after = _fd_count()
assert after == before, (
f"FD leak: {before} open before, {after} after. "
f"safe_open_unc must close the FD when readlink-validation rejects."
)
# And the same property holds for parse-time rejections, where
# os.open is never called.
before = _fd_count()
with pytest.raises(ReversePathError):
with safe_open_unc("not-a-unc-path", share_roots):
pass
assert _fd_count() == before
# ---- Hypothesis property test ------------------------------------
@pytest.mark.hypothesis
def test_property_translate_total_function(tmp_path):
"""For arbitrary text input the translator either returns a Path
under the share root or raises ReversePathError -- never crashes
with another exception, never returns a path outside the root.
"""
pytest.importorskip("hypothesis")
from hypothesis import given, settings
from hypothesis import strategies as st
share_root = tmp_path
share_roots = {"home": share_root}
resolved_root = share_root.resolve()
@given(st.text(max_size=200))
@settings(max_examples=200, deadline=None)
def _check(s: str) -> None:
try:
result = translate_unc_to_posix(s, share_roots)
except ReversePathError:
return
# If it returned, it must be a Path under the share root.
assert isinstance(result, Path)
assert is_relative_to(result, resolved_root), (
f"translator returned {result!r} not under {resolved_root!r} for input {s!r}"
)
_check()