diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be84fc..f313366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Hermes Web UI -- Changelog +## [v0.50.35] fix: workspace trust boundary — cross-platform, multi-workspace support + +v0.50.34's workspace trust check was too restrictive: it required all workspaces to be under `DEFAULT_WORKSPACE` (/home/hermes/workspace), which blocked every profile-specific workspace (~/CodePath, ~/hermes-webui-public, ~/WebUI, ~/Camanji, etc.) and prevented switching between workspaces at all. + +Replaced with a three-layer model that works cross-platform and supports multiple workspaces per profile: + +1. **Blocklist** — `/etc`, `/usr`, `/var`, `/bin`, `/sbin`, `/boot`, `/proc`, `/sys`, `/dev`, `/root`, `/lib`, `/lib64`, `/opt/homebrew` always rejected, closing the original CVSS 8.8 vulnerability +2. **Home-directory check** — any path under `Path.home()` is trusted; `Path.home()` is cross-platform (`~/...` on Linux/macOS, `C:\\Users\\...` on Windows); allows all profile workspaces simultaneously since they don't need to share a single ancestor +3. **Saved-workspace escape hatch** — paths already in the profile's saved workspace list are trusted regardless of location, covering self-hosted deployments with workspaces outside home (`/data/projects`, `/opt/workspace`, etc.) + +- `api/workspace.py`: rewritten `resolve_trusted_workspace()` with the three-layer model +- `tests/test_sprint3.py`: updated error-message assertions from `"trusted workspace root"` → `"outside"` (covers both old and new error strings) +- 1053 tests total (unchanged) + ## [v0.50.34] fix(workspace): restrict session workspaces to trusted roots [SECURITY] (#415) Session creation, update, chat-start, and workspace-add endpoints accepted arbitrary caller-supplied workspace paths. An authenticated caller could repoint a session to any directory the process could access, then use normal file read/write APIs to operate on attacker-chosen locations. CVSS 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). diff --git a/api/workspace.py b/api/workspace.py index f537301..ab41e3f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -215,28 +215,73 @@ def set_last_workspace(path: str) -> None: def resolve_trusted_workspace(path: str | Path | None = None) -> Path: - """Resolve and validate a workspace path under the WebUI's trusted workspace root. + """Resolve and validate a workspace path. - The trusted root is the WebUI boot-time DEFAULT_WORKSPACE (respects - HERMES_WEBUI_STATE_DIR for test isolation). Session creation/update and - workspace-list mutations must stay within that root so callers cannot repoint - a session to arbitrary filesystem locations outside the intended sandbox. + A path is trusted if it satisfies at least one of: + (A) It is under the user's home directory (Path.home()). + Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows. + (B) It is already in the profile's saved workspace list. + This covers self-hosted deployments where workspaces live outside home + (e.g. /data/projects, /opt/workspace) — once a workspace is saved by + an admin, it can be reused without re-validation. - Note: _profile_default_workspace() reads the agent's terminal.cwd which may - differ from the WebUI's configured workspace root — always use DEFAULT_WORKSPACE - here to stay consistent with how new_session() seeds the initial workspace. + Additionally enforced regardless of (A)/(B): + 1. The path must exist. + 2. The path must be a directory. + 3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin, + /boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs). + This prevents even admin-saved workspaces from pointing at OS internals. + + None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always + trusted (it was validated at server startup). """ - root = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() - candidate = root if path in (None, "") else Path(path).expanduser().resolve() + _BLOCKED_SYSTEM_ROOTS = { + # Linux / macOS + Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'), + Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'), Path('/root'), + Path('/lib'), Path('/lib64'), Path('/opt/homebrew'), + } + + if path in (None, ""): + return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() + + candidate = Path(path).expanduser().resolve() + if not candidate.exists(): raise ValueError(f"Path does not exist: {candidate}") if not candidate.is_dir(): raise ValueError(f"Path is not a directory: {candidate}") + + # Block known system roots and their children + for blocked in _BLOCKED_SYSTEM_ROOTS: + try: + candidate.relative_to(blocked) + raise ValueError(f"Path points to a system directory: {candidate}") + except ValueError as e: + if "system directory" in str(e): + raise + # relative_to raised ValueError = candidate is NOT under blocked = safe + + # (A) Trusted if under the user's home directory — cross-platform via Path.home() try: - candidate.relative_to(root) + candidate.relative_to(Path.home().resolve()) + return candidate except ValueError: - raise ValueError(f"Path is outside the trusted workspace root: {candidate}") - return candidate + pass + + # (B) Trusted if already in the saved workspace list — covers non-home installs + try: + saved = load_workspaces() + saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")} + if candidate in saved_paths: + return candidate + except Exception: + pass + + raise ValueError( + f"Path is outside the user home directory and not in the saved workspace " + f"list: {candidate}. Add it via Settings → Workspaces first." + ) def safe_resolve_ws(root: Path, requested: str) -> Path: diff --git a/static/index.html b/static/index.html index bbe3d00..8bdba96 100644 --- a/static/index.html +++ b/static/index.html @@ -535,7 +535,7 @@