fix(profiles): reject traversal-shaped profile names in get_profile_dir

A WS 'profile' param like '../../foo' normalized to a path component that
escaped the profiles root, letting a connected client bind an arbitrary
existing directory as a profile home (state.db opened there, and session
delete chains into per-id file cleanup under <dir>/sessions/).

get_profile_dir now validates the canonical name against the profile id
regex before joining it under profiles/. The regex only, not the reserved
list, so pre-reserved-list dirs like profiles/hermes keep resolving.
Callers that probe existence (profile_exists, _profile_home, the 4064
resolvers) treat ValueError as 'not found'.
This commit is contained in:
Adolanium
2026-09-11 18:39:41 +03:00
committed by Teknium
parent 6636b0896c
commit 2770f93064
6 changed files with 63 additions and 7 deletions

View File

@@ -234,15 +234,25 @@ def get_profile_dir(name: str) -> Path:
canon = normalize_profile_name(name)
if canon == "default":
return _get_default_hermes_home()
# The name becomes a path component under profiles/; refuse anything that
# is not a valid profile id so every caller (WS params, /p/<profile>/
# prefixes, tool args) fails closed instead of escaping the root. The
# regex only, not _RESERVED_NAMES: a pre-reserved-list dir like
# profiles/hermes may still exist and must keep resolving.
if not _PROFILE_ID_RE.match(canon):
raise ValueError(f"Invalid profile name {canon!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}")
return _get_profiles_root() / canon
def profile_exists(name: str) -> bool:
"""Check whether a live (non-tombstoned) profile directory exists."""
canon = normalize_profile_name(name)
try:
canon = normalize_profile_name(name)
profile_dir = get_profile_dir(canon)
except ValueError:
return False
if canon == "default":
return True
profile_dir = get_profile_dir(canon)
return profile_dir.is_dir() and not named_profile_is_deleted(profile_dir)

View File

@@ -105,6 +105,20 @@ class TestGetProfileDir:
result = get_profile_dir("default")
assert result == tmp_path / ".hermes"
def test_valid_name_resolves_under_profiles_root(self, profile_env):
assert get_profile_dir("coder") == _get_profiles_root() / "coder"
@pytest.mark.parametrize("name", ["..", "../outside", "../../tmp", "a/b", "a\\b", ".hidden", "has space"])
def test_traversal_and_invalid_names_rejected(self, name, profile_env):
# The name becomes a path component under profiles/; invalid ids must
# raise instead of escaping the root.
with pytest.raises(ValueError):
get_profile_dir(name)
@pytest.mark.parametrize("name", ["..", "../outside", "a/b"])
def test_profile_exists_false_for_invalid_names(self, name, profile_env):
assert profiles.profile_exists(name) is False
# ===================================================================
# TestCreateProfile

View File

@@ -57,3 +57,24 @@ def test_custom_root_basename_target_fails_closed_when_unavailable(tmp_path, mon
with pytest.raises(FileNotFoundError):
with server._profile_db({"profile": "customer-data"}):
pass
@pytest.mark.parametrize("name", ["..", "../outside", "../../tmp", "a/b", "a\\b", ".hidden"])
def test_profile_param_traversal_fails_closed(tmp_path, monkeypatch, name):
"""A traversal-shaped ``profile`` param must never resolve outside profiles/."""
from tui_gateway import server
home = tmp_path / ".hermes"
outside = tmp_path / "outside"
outside.mkdir(parents=True) # a real directory the traversal could land on
home.mkdir()
(home / "config.yaml").write_text("terminal:\n cwd: /launch\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(server, "_hermes_home", home)
with pytest.raises(FileNotFoundError):
server._profile_home(name)
with pytest.raises(FileNotFoundError):
with server._profile_db({"profile": name}):
pass

View File

@@ -67,7 +67,10 @@ def resolve_profile(rid, params, err_fn) -> Tuple[Optional[Any], Optional[dict]]
from hermes_cli.profiles import get_profile_dir
from hermes_constants import set_hermes_home_override
profile_dir = get_profile_dir(profile)
try:
profile_dir = get_profile_dir(profile)
except ValueError:
return None, err_fn(rid, 4064, f"profile '{profile}' not found")
if not profile_dir or not profile_dir.is_dir():
return None, err_fn(rid, 4064, f"profile '{profile}' not found")
return set_hermes_home_override(str(profile_dir)), None

View File

@@ -72,7 +72,10 @@ def _resolve_profile(rid, params):
if not name:
return name, None, _err(rid, 4063, "name required")
from hermes_cli.profiles import get_profile_dir
profile_dir = Path(get_profile_dir(name))
try:
profile_dir = Path(get_profile_dir(name))
except ValueError:
return name, None, _err(rid, 4064, f"profile '{name}' not found")
if not profile_dir.is_dir():
return name, None, _err(rid, 4064, f"profile '{name}' not found")
return name, profile_dir, None

View File

@@ -464,7 +464,9 @@ def _canonical_profile_request(name: str) -> str:
"""
if name.casefold() in {".hermes", "hermes"}:
from hermes_cli import profiles as profiles_mod
if not Path(profiles_mod.get_profile_dir(name)).is_dir():
# Check the profiles root directly: get_profile_dir rejects "hermes" as a
# reserved name, but a pre-reserved-list install may still carry that dir.
if not (profiles_mod._get_profiles_root() / profiles_mod.normalize_profile_name(name)).is_dir():
return "default"
return name
@@ -487,8 +489,11 @@ def _profile_home(profile: str | None) -> Path | None:
if not (name := _canonical_profile_request((profile or "").strip())):
return None
from hermes_cli import profiles as profiles_mod
home = Path(profiles_mod.get_profile_dir(name))
if not home.is_dir():
try:
home = Path(profiles_mod.get_profile_dir(name))
except ValueError:
home = None
if home is None or not home.is_dir():
raise FileNotFoundError(f"Profile '{name}' does not exist.")
if home.resolve() == Path(_hermes_home).resolve():
return None # already the launch profile (no override needed)