Ensure paths are not followed through parent links
This commit is contained in:
parent
e10a3f62b0
commit
07b07e60c5
9 changed files with 323 additions and 23 deletions
|
|
@ -221,6 +221,25 @@ def test_find_user_ssh_files_ignores_symlink(tmp_path: Path):
|
|||
assert result == []
|
||||
|
||||
|
||||
def test_find_user_ssh_files_ignores_symlinked_ssh_dir(tmp_path: Path):
|
||||
"""A user who replaces ~/.ssh with a symlink to a sensitive directory must
|
||||
not have files inside it harvested through the symlinked parent. os.path.isdir
|
||||
follows symlinks, so the directory itself must be checked with islink().
|
||||
"""
|
||||
|
||||
from enroll.accounts import find_user_ssh_files
|
||||
|
||||
sensitive = tmp_path / "sensitive"
|
||||
sensitive.mkdir()
|
||||
(sensitive / "authorized_keys").write_text("ssh-rsa AAAA...\n", encoding="utf-8")
|
||||
|
||||
home = tmp_path / "home" / "mallory"
|
||||
home.mkdir(parents=True)
|
||||
os.symlink(str(sensitive), str(home / ".ssh"))
|
||||
|
||||
assert find_user_ssh_files(str(home)) == []
|
||||
|
||||
|
||||
def test_find_user_ssh_files_handles_home_not_starting_with_slash():
|
||||
from enroll.accounts import find_user_ssh_files
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,54 @@ def test_stat_triplet_reports_mode(tmp_path: Path):
|
|||
assert mode == "0600"
|
||||
assert owner # non-empty string
|
||||
assert group # non-empty string
|
||||
|
||||
|
||||
def test_open_no_follow_path_reads_regular_file(tmp_path: Path):
|
||||
from enroll.fsutil import open_no_follow_path
|
||||
|
||||
nested = tmp_path / "a" / "b"
|
||||
nested.mkdir(parents=True)
|
||||
f = nested / "file.txt"
|
||||
f.write_text("hello\n", encoding="utf-8")
|
||||
|
||||
fd = open_no_follow_path(str(f))
|
||||
try:
|
||||
assert os.read(fd, 100) == b"hello\n"
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def test_open_no_follow_path_refuses_symlinked_parent(tmp_path: Path):
|
||||
import errno
|
||||
|
||||
from enroll.fsutil import open_no_follow_path
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
(real / "file.txt").write_text("x\n", encoding="utf-8")
|
||||
(tmp_path / "link").symlink_to(real)
|
||||
|
||||
try:
|
||||
fd = open_no_follow_path(str(tmp_path / "link" / "file.txt"))
|
||||
os.close(fd)
|
||||
raise AssertionError("expected OSError for symlinked parent")
|
||||
except OSError as e:
|
||||
assert e.errno == errno.ELOOP
|
||||
|
||||
|
||||
def test_open_no_follow_path_refuses_symlinked_leaf(tmp_path: Path):
|
||||
import errno
|
||||
|
||||
from enroll.fsutil import open_no_follow_path
|
||||
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("x\n", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
try:
|
||||
fd = open_no_follow_path(str(link))
|
||||
os.close(fd)
|
||||
raise AssertionError("expected OSError for symlinked leaf")
|
||||
except OSError as e:
|
||||
assert e.errno == errno.ELOOP
|
||||
|
|
|
|||
|
|
@ -102,7 +102,45 @@ def test_capture_file_rejects_symlink_source_with_ignore_policy(tmp_path: Path):
|
|||
|
||||
assert ok is False
|
||||
assert managed == []
|
||||
assert excluded and excluded[0].reason == "not_regular_file"
|
||||
# Symlinked sources are now reported with the dedicated symlink_component
|
||||
# reason (covers both symlinked leaves and symlinked parent directories),
|
||||
# which is more precise than the old generic not_regular_file.
|
||||
assert excluded and excluded[0].reason == "symlink_component"
|
||||
|
||||
|
||||
def test_capture_file_rejects_symlinked_parent_with_ignore_policy(tmp_path: Path):
|
||||
"""O_NOFOLLOW only guards the final component. A regular file reached
|
||||
through a symlinked *parent* directory must still be refused, otherwise a
|
||||
file whose real location is deny-globbed could be captured while its
|
||||
logical (recorded) path looks safe.
|
||||
"""
|
||||
|
||||
secret = tmp_path / "secretroot"
|
||||
secret.mkdir()
|
||||
(secret / "config").write_text("listen_port=8080\n", encoding="utf-8")
|
||||
(tmp_path / "allowed").symlink_to(secret, target_is_directory=True)
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
|
||||
managed: list[ManagedFile] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
ok = capture_file(
|
||||
bundle_dir=str(bundle),
|
||||
role_name="role",
|
||||
abs_path=str(tmp_path / "allowed" / "config"),
|
||||
reason="test",
|
||||
policy=IgnorePolicy(),
|
||||
path_filter=PathFilter(),
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert managed == []
|
||||
assert excluded and excluded[0].reason == "symlink_component"
|
||||
# Nothing should have been written into the bundle.
|
||||
artifact = bundle / "artifacts" / "role" / "allowed" / "config"
|
||||
assert not artifact.exists()
|
||||
|
||||
|
||||
def test_prepare_new_private_dir_rejects_symlink_parent(tmp_path: Path):
|
||||
|
|
|
|||
|
|
@ -236,7 +236,10 @@ def test_deny_reason_symlink_file(tmp_path: Path):
|
|||
link = tmp_path / "link"
|
||||
os.symlink(str(real_file), str(link))
|
||||
reason = pol.deny_reason(str(link))
|
||||
assert reason == "not_regular_file"
|
||||
# A symlinked path (final component or parent) is refused with the
|
||||
# dedicated symlink_component reason so operators can tell symlink
|
||||
# redirection apart from genuine non-regular files (sockets, devices).
|
||||
assert reason == "symlink_component"
|
||||
|
||||
|
||||
def test_deny_reason_logs(tmp_path: Path):
|
||||
|
|
@ -358,3 +361,58 @@ def test_noncanonical_backup_and_log_fastpaths():
|
|||
assert pol._path_deny_reason("/var/log/foo/../bar.log") == "log_file"
|
||||
assert pol._path_deny_reason("/etc/foo/../something~") == "backup_file"
|
||||
assert pol._path_deny_reason("/etc//passwd-") == "backup_file"
|
||||
|
||||
|
||||
def test_inspect_file_refuses_symlinked_parent_directory(tmp_path: Path):
|
||||
"""A regular file reached through a symlinked *parent* directory must be
|
||||
refused, even though O_NOFOLLOW alone would only guard the final
|
||||
component. Otherwise a file whose real location is deny-globbed (or whose
|
||||
content is benign) could be captured while its logical path looks safe.
|
||||
"""
|
||||
|
||||
pol = IgnorePolicy()
|
||||
secret = tmp_path / "secretroot"
|
||||
secret.mkdir()
|
||||
(secret / "config").write_text("listen_port=8080\n", encoding="utf-8")
|
||||
(tmp_path / "allowed").symlink_to(secret)
|
||||
|
||||
reason, inspection = pol.inspect_file(str(tmp_path / "allowed" / "config"))
|
||||
assert reason == "symlink_component"
|
||||
assert inspection is None
|
||||
|
||||
|
||||
def test_inspect_file_refuses_denyglob_evasion_via_symlinked_parent(tmp_path: Path):
|
||||
"""The strongest variant: the real file lives under a deny-globbed dir,
|
||||
but is reached via a symlinked parent so the *logical* path does not match
|
||||
the deny glob. Content is non-secret-looking (DH params), so only the
|
||||
parent-symlink check stands between the operator and disclosure.
|
||||
"""
|
||||
|
||||
pol = IgnorePolicy()
|
||||
realdir = tmp_path / "ssl_private"
|
||||
realdir.mkdir()
|
||||
(realdir / "dhparam.pem").write_text(
|
||||
"-----BEGIN DH PARAMETERS-----\nMII...\n-----END DH PARAMETERS-----\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "innocent").symlink_to(realdir)
|
||||
|
||||
reason, inspection = pol.inspect_file(str(tmp_path / "innocent" / "dhparam.pem"))
|
||||
assert reason == "symlink_component"
|
||||
assert inspection is None
|
||||
|
||||
|
||||
def test_inspect_file_still_captures_normal_nested_file(tmp_path: Path):
|
||||
"""Regression guard: ordinary files in real (non-symlinked) directories
|
||||
must still be inspected and returned.
|
||||
"""
|
||||
|
||||
pol = IgnorePolicy()
|
||||
nested = tmp_path / "etc" / "myapp"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "app.conf").write_text("workers=4\n", encoding="utf-8")
|
||||
|
||||
reason, inspection = pol.inspect_file(str(nested / "app.conf"))
|
||||
assert reason is None
|
||||
assert inspection is not None
|
||||
assert inspection.data == b"workers=4\n"
|
||||
|
|
|
|||
Reference in a new issue