Files
hermes-webui/tests/test_workspace_blocked_roots_macos.py
T
nesquena-hermesandrodboev b3e0896356 fix(workspace): harden remote POSIX workspace trust boundaries (#3664) (#4273)
Normalize both the candidate and terminal.cwd before the remote-POSIX
containment check so `{cwd}/../other/...` lexical escapes are rejected, and
restore the blocked-system-root gate BELOW the home-directory allow-list in
both resolve_trusted_workspace() and validate_workspace_to_add() so valid
systemd-homed paths under /var/home/... stay allowed. Also normalize POSIX
shapes at the source (_as_posix_path) so every downstream block probe is
shape-consistent (/etc/../home/user -> /home/user).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-16 18:58:31 +00:00

188 lines
7.8 KiB
Python

"""
Regression tests for the macOS symlink leg of the workspace blocked-roots check.
On macOS, ``/etc``, ``/var``, and ``/tmp`` are symlinks to ``/private/etc``,
``/private/var``, and ``/private/tmp``. ``Path('/etc').resolve()`` returns
``/private/etc`` — so a literal-only blocked-roots set would miss the
resolved candidate and let the user register ``/etc`` as a workspace.
Conversely, ``/private/var/folders/<hash>/T/`` is the per-user tmp tree
(this is where pytest's ``tmp_path_factory`` writes), and must remain a
valid workspace candidate even though it lives nominally under ``/var``.
The ``TestEtcAlwaysBlocked`` and ``TestVarSystemBlockedButUserTmpAllowed``
classes contain checks that depend on the macOS layout (where ``/etc``
resolves to ``/private/etc``); those tests are skipped on Linux where
``/etc.resolve() == /etc`` and the ``/private/*`` aliases simply don't exist.
The cross-platform invariants (``/etc`` literal blocked, ``/tmp`` allowed,
non-symlink roots blocked) run on every platform.
"""
import sys
from pathlib import Path
import pytest
from api.workspace import (
_USER_TMP_PREFIXES,
_is_blocked_system_path,
_workspace_blocked_roots,
)
_IS_MACOS = sys.platform == 'darwin'
_macos_only = pytest.mark.skipif(not _IS_MACOS, reason="macOS-specific symlink layout")
# ── Blocked-roots set includes both literal and resolved forms ──────────────
class TestBlockedRootsCanonicalisation:
def test_etc_literal_in_blocked_roots(self):
assert Path('/etc') in _workspace_blocked_roots()
def test_etc_resolved_in_blocked_roots(self):
"""``/etc.resolve()`` is ``/private/etc`` on macOS; same path on Linux.
Either way the resolved form must appear in the set so a candidate
that crossed a symlink during ``.resolve()`` still matches."""
resolved = Path('/etc').resolve()
assert resolved in _workspace_blocked_roots()
def test_var_literal_and_resolved_in_blocked_roots(self):
assert Path('/var') in _workspace_blocked_roots()
assert Path('/var').resolve() in _workspace_blocked_roots()
# ── /etc is rejected on both Linux and macOS ────────────────────────────────
class TestEtcAlwaysBlocked:
def test_etc_resolved_form_blocked(self):
"""The path-after-resolve form (``/private/etc`` on macOS, ``/etc`` on
Linux) must be blocked."""
assert _is_blocked_system_path(Path('/etc').resolve())
def test_etc_subpath_blocked(self):
assert _is_blocked_system_path(Path('/etc/hostname').resolve())
@_macos_only
def test_private_etc_explicit_blocked(self):
"""Even if the user writes ``/private/etc`` directly (knowing the
macOS layout), it must still be blocked. Skipped on Linux —
``/private/etc`` doesn't exist there and ``Path('/etc').resolve()``
is ``/etc`` so the resolved-form aliasing is a no-op."""
assert _is_blocked_system_path(Path('/private/etc'))
# ── /var is selectively blocked (system parts) but tmp carve-outs work ──────
class TestVarSystemBlockedButUserTmpAllowed:
def test_var_log_blocked(self):
"""``/private/var/log`` would have been a macOS-only security gap
before this fix — it resolved through the ``/var`` symlink and
didn't match the literal blocked root."""
assert _is_blocked_system_path(Path('/var/log').resolve())
@_macos_only
def test_private_var_log_blocked(self):
"""Skipped on Linux: ``/private/var/log`` isn't an alias for
``/var/log`` there; ``Path('/var').resolve() == /var`` so no
``/private/var`` ever lands in the blocked set."""
assert _is_blocked_system_path(Path('/private/var/log'))
def test_var_folders_user_tmp_allowed(self):
"""macOS per-user tmp under /var/folders/<hash>/T/ — pytest's
tmp_path_factory writes here. Must remain registerable."""
# This path may not actually exist; the carve-out is path-shape based.
candidate = Path('/var/folders/abc/T/some-test-dir')
assert not _is_blocked_system_path(candidate)
def test_private_var_folders_user_tmp_allowed(self):
candidate = Path('/private/var/folders/abc/T/some-test-dir')
assert not _is_blocked_system_path(candidate)
def test_var_tmp_user_writable_allowed(self):
"""``/var/tmp`` is system-wide user-writable tmp on Linux/macOS.
Carved out so users can register tmp dirs there."""
assert not _is_blocked_system_path(Path('/var/tmp/my-workspace'))
assert not _is_blocked_system_path(Path('/private/var/tmp/my-workspace'))
# ── Carve-out invariants ───────────────────────────────────────────────────
class TestUserTmpPrefixes:
def test_var_folders_in_carveouts(self):
assert Path('/var/folders') in _USER_TMP_PREFIXES
assert Path('/private/var/folders') in _USER_TMP_PREFIXES
def test_var_tmp_in_carveouts(self):
assert Path('/var/tmp') in _USER_TMP_PREFIXES
assert Path('/private/var/tmp') in _USER_TMP_PREFIXES
def test_carveouts_only_loosen_var_subtree(self):
"""Carve-outs must not let /etc or other strict roots through."""
for tmp in _USER_TMP_PREFIXES:
# tmp paths are under /var or /private/var, never under /etc, /usr, /bin, etc.
assert tmp.as_posix().startswith('/var/') or tmp.as_posix().startswith('/private/var/')
# ── Other roots: literal == resolved on both platforms ─────────────────────
class TestNonSymlinkRootsUnchanged:
@pytest.mark.parametrize("root", [
'/usr', '/bin', '/sbin', '/proc', '/sys', '/dev', '/lib', '/opt/homebrew',
])
def test_root_blocked(self, root):
# Whether or not the root exists, the literal form must be blocked.
assert _is_blocked_system_path(Path(root))
# And the resolved form (almost always equal to literal for these).
assert _is_blocked_system_path(Path(root).resolve())
@pytest.mark.parametrize("subpath", [
'/usr/local/bin/something',
'/proc/self/maps',
'/sys/class/net',
'/dev/null',
])
def test_subpath_blocked(self, subpath):
# Use Path() not .resolve() — we want to assert the shape-based block,
# not test whether the path actually exists on the test runner.
assert _is_blocked_system_path(Path(subpath))
# ── New macOS-specific blocked roots: /System and /Library ──────────────────
class TestMacOSSystemAndLibraryBlocked:
"""macOS has /System and /Library as top-level OS directories that must be
blocked even on Linux (where they don't exist) since the path shapes are
meaningful on macOS and should always be rejected.
"""
@pytest.mark.parametrize("path", [
'/Library',
'/Library/Application Support',
'/Library/Preferences',
'/System',
'/System/Library',
'/System/Library/CoreServices',
])
def test_macos_os_roots_blocked(self, path):
"""Paths under /Library and /System must be blocked regardless of platform."""
from api.workspace import _is_blocked_workspace_path
assert _is_blocked_workspace_path(Path(path))
class TestPosixShapeNormalization:
def test_etc_parent_escape_is_not_falsely_blocked(self):
from api.workspace import _is_blocked_workspace_path
assert not _is_blocked_workspace_path(Path('/home/user'), '/etc/../home/user')
def test_dot_prefixed_etc_path_is_still_blocked(self):
from api.workspace import _is_blocked_workspace_path
assert _is_blocked_workspace_path(Path('/etc/ssh'), '/./etc/ssh')