fix(profiles): block path traversal in switch and delete flows

This commit is contained in:
hinotoi-agent
2026-04-14 11:53:10 +08:00
parent fc43b897c5
commit aae7a30647
3 changed files with 89 additions and 4 deletions
+21 -2
View File
@@ -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:
+5 -2
View File
@@ -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:
+63
View File
@@ -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()