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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user