diff --git a/api/profiles.py b/api/profiles.py index e6863400..bed35955 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -176,7 +176,7 @@ def switch_profile(name: str) -> dict: if name == 'default': home = _DEFAULT_HERMES_HOME else: - home = _DEFAULT_HERMES_HOME / 'profiles' / name + home = _resolve_named_profile_home(name) if not home.is_dir(): raise ValueError(f"Profile '{name}' does not exist.") @@ -267,6 +267,24 @@ def _validate_profile_name(name: str): ) +def _profiles_root() -> Path: + """Return the canonical root that contains named profiles.""" + return (_DEFAULT_HERMES_HOME / 'profiles').resolve() + + +def _resolve_named_profile_home(name: str) -> Path: + """Resolve a named profile to a directory under the profiles root. + + Validates *name* as a logical profile identifier first, then resolves the + final filesystem path and enforces containment under ~/.hermes/profiles. + """ + _validate_profile_name(name) + profiles_root = _profiles_root() + candidate = (profiles_root / name).resolve() + candidate.relative_to(profiles_root) + return candidate + + def _create_profile_fallback(name: str, clone_from: str = None, clone_config: bool = False) -> Path: """Create a profile directory without hermes_cli (Docker/standalone fallback).""" @@ -385,6 +403,7 @@ def delete_profile_api(name: str) -> dict: """Delete a profile. Switches to default first if it's the active one.""" if name == 'default': raise ValueError("Cannot delete the default profile.") + _validate_profile_name(name) # If deleting the active profile, switch to default first if _active_profile == name: @@ -402,7 +421,7 @@ def delete_profile_api(name: str) -> dict: except ImportError: # Manual fallback: just remove the directory import shutil - profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name + profile_dir = _resolve_named_profile_home(name) if profile_dir.is_dir(): shutil.rmtree(str(profile_dir)) else: diff --git a/api/routes.py b/api/routes.py index b4d9bd84..aa349a6b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -761,8 +761,10 @@ def handle_post(handler, parsed) -> bool: if not name: return bad(handler, "name is required") try: - from api.profiles import switch_profile + from api.profiles import switch_profile, _validate_profile_name + if name != 'default': + _validate_profile_name(name) result = switch_profile(name) return j(handler, result) except (ValueError, FileNotFoundError) as e: @@ -809,8 +811,9 @@ def handle_post(handler, parsed) -> bool: if not name: return bad(handler, "name is required") try: - from api.profiles import delete_profile_api + from api.profiles import delete_profile_api, _validate_profile_name + _validate_profile_name(name) result = delete_profile_api(name) return j(handler, result) except (ValueError, FileNotFoundError) as e: diff --git a/tests/test_profile_path_security.py b/tests/test_profile_path_security.py new file mode 100644 index 00000000..0a2dfc69 --- /dev/null +++ b/tests/test_profile_path_security.py @@ -0,0 +1,63 @@ +import importlib +import os +import sys +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def _reload_profiles_module(base_home: Path): + os.environ["HERMES_BASE_HOME"] = str(base_home) + os.environ["HERMES_HOME"] = str(base_home) + + for name in ["api.config", "api.profiles"]: + if name in sys.modules: + del sys.modules[name] + + profiles = importlib.import_module("api.profiles") + return profiles + + +def test_switch_profile_rejects_path_traversal(): + with tempfile.TemporaryDirectory() as td: + temp_root = Path(td) + base = temp_root / ".hermes" + (base / "profiles").mkdir(parents=True) + (temp_root / "escape-target").mkdir() + + profiles = _reload_profiles_module(base) + + with pytest.raises(ValueError): + profiles.switch_profile("../../escape-target") + + +def test_delete_profile_rejects_path_traversal(): + with tempfile.TemporaryDirectory() as td: + temp_root = Path(td) + base = temp_root / ".hermes" + (base / "profiles").mkdir(parents=True) + (temp_root / "escape-target").mkdir() + + profiles = _reload_profiles_module(base) + + with pytest.raises(ValueError): + profiles.delete_profile_api("../../escape-target") + + +def test_switch_profile_allows_valid_profile_name(): + with tempfile.TemporaryDirectory() as td: + temp_root = Path(td) + base = temp_root / ".hermes" + profile_dir = base / "profiles" / "demo" + profile_dir.mkdir(parents=True) + + profiles = _reload_profiles_module(base) + result = profiles.switch_profile("demo") + + assert result["active"] == "demo" + assert Path(os.environ["HERMES_HOME"]).resolve() == profile_dir.resolve()