Skip to content

Commit 2530072

Browse files
author
maymuneth
committed
fix(security): reject path traversal in credential file registration
1 parent 0b0c1b3 commit 2530072

2 files changed

Lines changed: 142 additions & 4 deletions

File tree

tests/tools/test_credential_files.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,110 @@ def test_empty_when_no_skills_dir(self, tmp_path):
197197

198198
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
199199
assert iter_skills_files() == []
200+
201+
class TestPathTraversalSecurity:
202+
"""Path traversal and absolute path rejection.
203+
204+
A malicious skill could declare::
205+
206+
required_credential_files:
207+
- path: '../../.ssh/id_rsa'
208+
209+
Without containment checks, this would mount the host's SSH private key
210+
into the container sandbox, leaking it to the skill's execution environment.
211+
"""
212+
213+
def test_dotdot_traversal_rejected(self, tmp_path, monkeypatch):
214+
"""'../sensitive' must not escape HERMES_HOME."""
215+
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
216+
(tmp_path / ".hermes").mkdir()
217+
218+
# Create a sensitive file one level above hermes_home
219+
sensitive = tmp_path / "sensitive.json"
220+
sensitive.write_text('{"secret": "value"}')
221+
222+
result = register_credential_file("../sensitive.json")
223+
224+
assert result is False
225+
assert get_credential_file_mounts() == []
226+
227+
def test_deep_traversal_rejected(self, tmp_path, monkeypatch):
228+
"""'../../etc/passwd' style traversal must be rejected."""
229+
hermes_home = tmp_path / ".hermes"
230+
hermes_home.mkdir()
231+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
232+
233+
# Create a fake sensitive file outside hermes_home
234+
ssh_dir = tmp_path / ".ssh"
235+
ssh_dir.mkdir()
236+
(ssh_dir / "id_rsa").write_text("PRIVATE KEY")
237+
238+
result = register_credential_file("../../.ssh/id_rsa")
239+
240+
assert result is False
241+
assert get_credential_file_mounts() == []
242+
243+
def test_absolute_path_rejected(self, tmp_path, monkeypatch):
244+
"""Absolute paths must be rejected regardless of whether they exist."""
245+
hermes_home = tmp_path / ".hermes"
246+
hermes_home.mkdir()
247+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
248+
249+
# Create a file at an absolute path
250+
sensitive = tmp_path / "absolute.json"
251+
sensitive.write_text("{}")
252+
253+
result = register_credential_file(str(sensitive))
254+
255+
assert result is False
256+
assert get_credential_file_mounts() == []
257+
258+
def test_legitimate_file_still_works(self, tmp_path, monkeypatch):
259+
"""Normal files inside HERMES_HOME must still be registered."""
260+
hermes_home = tmp_path / ".hermes"
261+
hermes_home.mkdir()
262+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
263+
(hermes_home / "token.json").write_text('{"token": "abc"}')
264+
265+
result = register_credential_file("token.json")
266+
267+
assert result is True
268+
mounts = get_credential_file_mounts()
269+
assert len(mounts) == 1
270+
assert "token.json" in mounts[0]["container_path"]
271+
272+
def test_nested_subdir_inside_hermes_home_allowed(self, tmp_path, monkeypatch):
273+
"""Files in subdirectories of HERMES_HOME must be allowed."""
274+
hermes_home = tmp_path / ".hermes"
275+
hermes_home.mkdir()
276+
subdir = hermes_home / "creds"
277+
subdir.mkdir()
278+
(subdir / "oauth.json").write_text("{}")
279+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
280+
281+
result = register_credential_file("creds/oauth.json")
282+
283+
assert result is True
284+
285+
def test_symlink_traversal_rejected(self, tmp_path, monkeypatch):
286+
"""A symlink inside HERMES_HOME pointing outside must be rejected."""
287+
hermes_home = tmp_path / ".hermes"
288+
hermes_home.mkdir()
289+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
290+
291+
# Create a sensitive file outside hermes_home
292+
sensitive = tmp_path / "sensitive.json"
293+
sensitive.write_text('{"secret": "value"}')
294+
295+
# Create a symlink inside hermes_home pointing outside
296+
symlink = hermes_home / "evil_link.json"
297+
try:
298+
symlink.symlink_to(sensitive)
299+
except (OSError, NotImplementedError):
300+
pytest.skip("Symlinks not supported on this platform")
301+
302+
result = register_credential_file("evil_link.json")
303+
304+
# The resolved path escapes HERMES_HOME — must be rejected
305+
assert result is False
306+
assert get_credential_file_mounts() == []

tools/credential_files.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,47 @@ def register_credential_file(
5555
5656
*relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``).
5757
Returns True if the file exists on the host and was registered.
58+
59+
Security: rejects absolute paths and path traversal sequences (``..``).
60+
The resolved host path must remain inside HERMES_HOME so that a malicious
61+
skill cannot declare ``required_credential_files: ['../../.ssh/id_rsa']``
62+
and exfiltrate sensitive host files into a container sandbox.
5863
"""
5964
hermes_home = _resolve_hermes_home()
65+
66+
# Reject absolute paths — they bypass the HERMES_HOME sandbox entirely.
67+
if os.path.isabs(relative_path):
68+
logger.warning(
69+
"credential_files: rejected absolute path %r (must be relative to HERMES_HOME)",
70+
relative_path,
71+
)
72+
return False
73+
6074
host_path = hermes_home / relative_path
61-
if not host_path.is_file():
62-
logger.debug("credential_files: skipping %s (not found)", host_path)
75+
76+
# Resolve symlinks and normalise ``..`` before the containment check so
77+
# that traversal like ``../. ssh/id_rsa`` cannot escape HERMES_HOME.
78+
try:
79+
resolved = host_path.resolve()
80+
hermes_home_resolved = hermes_home.resolve()
81+
resolved.relative_to(hermes_home_resolved) # raises ValueError if outside
82+
except ValueError:
83+
logger.warning(
84+
"credential_files: rejected path traversal %r "
85+
"(resolves to %s, outside HERMES_HOME %s)",
86+
relative_path,
87+
resolved,
88+
hermes_home_resolved,
89+
)
90+
return False
91+
92+
if not resolved.is_file():
93+
logger.debug("credential_files: skipping %s (not found)", resolved)
6394
return False
6495

6596
container_path = f"{container_base.rstrip('/')}/{relative_path}"
66-
_registered_files[container_path] = str(host_path)
67-
logger.debug("credential_files: registered %s -> %s", host_path, container_path)
97+
_registered_files[container_path] = str(resolved)
98+
logger.debug("credential_files: registered %s -> %s", resolved, container_path)
6899
return True
69100

70101

0 commit comments

Comments
 (0)