Files
hermes-webui/tests/test_workspace_blocked_roots_macos.py
T
nesquena-hermes 7189416969 fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes (#1206)
fix: batch v0.50.234-235 — XSS hardening, workspace validation, profile switch fixes

v0.50.235 (#1203 — profile switch workspace/model/chip, 3 bugs + flaky test):
- switch_profile now reads target profile's workspace directly (thread-local bypass)
- invalidate_models_cache() after profile switch (model dropdown staleness)
- syncTopbar() updates chip before early-return (no-session path)

v0.50.234 (#1201/#1205 — XSS hardening + workspace security):
- renderMd() full HTML attribute sanitizer replacing tag-name-only allowlist
- Delegated image lightbox (removes all inline onclick)
- macOS /etc → /private/etc symlink bypass fixed
- /System /Library added to blocked workspace roots
- Legacy /api/chat workspace trust gap closed

Both PRs independently reviewed. 2787/2787 tests. QA harness 20/20 + 11/11 API checks.

Co-authored-by: Brendan Schmid <bschmidy10@Wilson.bschmidy10>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-27 21:39:30 -07:00

176 lines
7.3 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 str(tmp).startswith('/var/') or str(tmp).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))