fix: cross-platform multi-workspace trust boundary (#417)
* fix: relax workspace trust boundary to user home directory The previous restriction required workspaces to be under DEFAULT_WORKSPACE (/home/hermes/workspace), which blocked all profile-specific workspaces (~/CodePath, ~/General, ~/WebUI, ~/Camanji, etc.) since each profile uses a different directory under home. New boundary: any directory under Path.home() is trusted. This still blocks /etc, /tmp, /var, /root, /usr and all paths outside the user's home, while allowing any legitimate workspace under ~/ Also updates test assertions from 'trusted workspace root' to 'outside' since the new error message says 'outside the user home directory'. * fix: workspace trust uses home-dir + saved-list, not single ancestor Three-layer trust model that works cross-platform and multi-workspace: 1. BLOCKLIST: /etc, /usr, /var, /bin, /sbin, /boot, /proc, /sys, /dev, /root, /lib, /lib64, /opt/homebrew — always rejected, even if somehow saved 2. HOME CHECK: any path under Path.home() is trusted — covers ~/CodePath, ~/hermes-webui-public, ~/WebUI, ~/General, ~/Camanji simultaneously; Path.home() is cross-platform (Linux ~/..., macOS ~/..., Windows C:\Users\...\...) 3. SAVED LIST ESCAPE HATCH: if a path is already in the saved workspace list, it's trusted regardless of location — covers self-hosted deployments where workspaces live outside home (/data/projects, /opt/workspace, etc.) None/empty → DEFAULT_WORKSPACE (always trusted, validated at startup) * docs: v0.50.35 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
14
CHANGELOG.md
14
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).
|
||||
|
||||
@@ -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(root)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path is outside the trusted workspace root: {candidate}")
|
||||
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(Path.home().resolve())
|
||||
return candidate
|
||||
except ValueError:
|
||||
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:
|
||||
|
||||
@@ -535,7 +535,7 @@
|
||||
<div class="settings-section-title">System</div>
|
||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||
</div>
|
||||
<span class="settings-version-badge">v0.50.34</span>
|
||||
<span class="settings-version-badge">v0.50.35</span>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
|
||||
@@ -153,7 +153,7 @@ def test_session_update_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/session/update", {"session_id": sid, "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "trusted workspace root" in result.get("error", "").lower()
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
|
||||
|
||||
def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
@@ -163,7 +163,7 @@ def test_chat_start_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/chat/start", {"session_id": sid, "message": "hello", "workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "trusted workspace root" in result.get("error", "").lower()
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
|
||||
|
||||
def test_workspace_add_rejects_path_outside_trusted_root(tmp_path):
|
||||
@@ -171,7 +171,7 @@ def test_workspace_add_rejects_path_outside_trusted_root(tmp_path):
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/workspaces/add", {"path": str(outside), "name": "Outside"})
|
||||
assert status == 400
|
||||
assert "trusted workspace root" in result.get("error", "").lower()
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
|
||||
|
||||
def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
@@ -179,7 +179,7 @@ def test_session_new_rejects_workspace_outside_trusted_root(tmp_path):
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
result, status = post("/api/session/new", {"workspace": str(outside)})
|
||||
assert status == 400
|
||||
assert "trusted workspace root" in result.get("error", "").lower()
|
||||
assert "outside" in result.get("error", "").lower()
|
||||
|
||||
|
||||
def test_session_search_returns_matches(cleanup_test_sessions):
|
||||
|
||||
Reference in New Issue
Block a user