Skip to content

Commit 8744a51

Browse files
authored
fix(security): validate user-influenced file paths (py/path-injection) (#2252)
## Summary CodeQL flagged 58 open `py/path-injection` alerts. Three were real gaps, now fixed; the other 41 were false positives (paths already fully validated upstream by an existing sanitizer CodeQL's dataflow engine doesn't recognize) — dismissed individually with a per-alert justification. - **Hub agent install directory traversal**: `agent_install_dir()`/`_backup_dir()` joined `agent_id` onto `~/.gaia/agents/` for `rmtree`/`move`/write with no shape check. An id like `../../evil` (reachable via `POST /api/agents/install` or a corrupt sentinel) could point install/uninstall/rollback/configure at an arbitrary directory. Now rejected with `InstallError` unless the id is a single safe path component. - **`safe_open_document` symlink-directory escape**: the home-directory containment check used a lexical `os.path.abspath` comparison, which an intermediate symlinked directory *inside* home (pointing outside) could bypass — `O_NOFOLLOW` only blocks a symlinked *final* path component. Added an `os.path.realpath`-based physical containment check. - **EMR `compute_file_hash()` missing its own guard**: three call sites in `gaia_agent_emr/agent.py` omitted the existing `allowed_dir` parameter, so the hashing helper had no containment check at all if reached with an untrusted path. ### Files fixed (with traversal tests) - `src/gaia/hub/installer.py`, `src/gaia/hub/lifecycle.py`, `src/gaia/ui/routers/hub.py` — new `_require_safe_agent_id()` guard + sentinel `executable` field validation. Tests: `tests/unit/test_hub_installer.py::TestAgentIdPathSafety` (traversal, absolute path, null byte, oversized id, non-string id, sentinel with unsafe executable). - `src/gaia/ui/utils.py` — realpath containment check in `safe_open_document()`. Test: `tests/unit/chat/ui/test_toctou.py::test_safe_open_rejects_symlinked_directory_escape`. - `hub/agents/python/emr/gaia_agent_emr/agent.py` — pass `allowed_dir` at all three `compute_file_hash()` call sites. Tests: `tests/unit/test_file_watcher.py::TestFileHashUtilities` (traversal, absolute escape, contained-path-still-works). ### Alerts dismissed as false positive (41, listed by number — each has a per-alert justification in its dismissal comment) - `documents.py` upload-path lock key + `index_folder()`: 231, 184–188 (path already gated by `safe_open_document`/`ensure_within_home` downstream) - `files.py` browse/search/preview/image endpoints: 189–206, 244–248 (all gated by `ensure_within_home()`, or the search scan roots are hardcoded to home subdirs with the user param only a filename substring filter) - `server.py` SPA static asset serving: 341, 342 (gated by `sanitize_static_path()`, a real containment-checking sanitizer) - `utils.py` sanitizer return: 209 (the sanitizer's own already-validated return value) - `file_watcher.py` `FileWatcher.__init__`: 315 (no caller passes remote/HTTP-derived input) - EMR dashboard/agent: 328–335 (watch_dir is set only via a fully-validated setter — regex allowlist, normpath/abspath, realpath symlink check, home-dir prefix, sensitive-dir denylist — before any of these lines run) ## Test plan - [ ] `PYTHONPATH=src python -m pytest tests/unit/test_hub_installer.py tests/unit/test_hub_lifecycle.py tests/unit/test_hub_router.py tests/unit/test_hub_security.py tests/unit/chat/ui/test_toctou.py tests/unit/test_file_watcher.py -q` — 203 passed - [ ] `python util/lint.py --all --fix` — all checks pass
1 parent c921fa7 commit 8744a51

8 files changed

Lines changed: 284 additions & 16 deletions

File tree

hub/agents/python/emr/gaia_agent_emr/agent.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def _print_file_listing(
264264
size_str = "?"
265265

266266
# Compute hash for status check
267-
file_hash = compute_file_hash(f)
267+
file_hash = compute_file_hash(f, allowed_dir=str(self._watch_dir))
268268
hash_display = file_hash[:8] + "..." if file_hash else "?"
269269

270270
if file_hash and file_hash in processed_hashes:
@@ -336,7 +336,7 @@ def _process_existing_files(self) -> None:
336336
if new_count > 0:
337337
self.console.print_info(f"Processing {new_count} new file(s)...")
338338
for f in sorted(existing_files):
339-
file_hash = compute_file_hash(f)
339+
file_hash = compute_file_hash(f, allowed_dir=str(self._watch_dir))
340340
if file_hash and file_hash not in processed_hashes:
341341
self._on_file_created(f)
342342

@@ -461,7 +461,7 @@ def _process_intake_form(self, file_path: str) -> Optional[Dict[str, Any]]:
461461

462462
# Step 2: Check for duplicates
463463
self._emit_progress(filename, 2, total_steps, "Checking for duplicates")
464-
file_hash = compute_file_hash(path)
464+
file_hash = compute_file_hash(path, allowed_dir=str(self._watch_dir))
465465
if file_hash:
466466
existing = self.query(
467467
"SELECT id, first_name, last_name FROM patients WHERE file_hash = ?",

src/gaia/hub/installer.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import hashlib
3030
import json
3131
import os
32+
import re
3233
import shutil
3334
import stat
3435
import subprocess
@@ -122,17 +123,48 @@ class NotInstalledError(InstallError):
122123
# Paths
123124
# ---------------------------------------------------------------------------
124125

126+
# Superset of the hub manifest id slug (manifest._ID_RE) that also tolerates
127+
# builtin/custom ids with uppercase, '.', or '_'. The security property is
128+
# that a valid id is exactly ONE path component: no '/', '\', ':', null, no
129+
# leading '.', so ``install_root / agent_id`` can never escape install_root.
130+
_AGENT_ID_SAFE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
131+
132+
133+
def _require_safe_agent_id(agent_id: str) -> str:
134+
"""Return *agent_id* if it is safe to use as a directory name.
135+
136+
Agent ids reach this module from untrusted surfaces (the Agent UI's
137+
``POST /api/agents/install`` body, the downloaded hub catalog), and are
138+
joined onto ``~/.gaia/agents/`` for destructive operations (rmtree,
139+
move, write). Reject anything that is not a single sane path component.
140+
141+
Raises:
142+
InstallError: If *agent_id* could traverse outside the install root.
143+
"""
144+
if not isinstance(agent_id, str) or not _AGENT_ID_SAFE_RE.match(agent_id):
145+
raise InstallError(
146+
f"Invalid agent id {agent_id!r}: agent ids must start with a letter "
147+
"or digit and contain only letters, digits, '.', '_' or '-' "
148+
"(max 128 chars). Check the id against the hub catalog "
149+
"('gaia agent list') or the package's gaia-agent.yaml."
150+
)
151+
return agent_id
152+
125153

126154
def default_install_root() -> Path:
127155
return Path.home() / ".gaia" / "agents"
128156

129157

130158
def agent_install_dir(agent_id: str, install_root: Optional[Path] = None) -> Path:
131-
return (install_root or default_install_root()) / agent_id
159+
return (install_root or default_install_root()) / _require_safe_agent_id(agent_id)
132160

133161

134162
def _backup_dir(agent_id: str, install_root: Optional[Path] = None) -> Path:
135-
return (install_root or default_install_root()) / BACKUP_DIRNAME / agent_id
163+
return (
164+
(install_root or default_install_root())
165+
/ BACKUP_DIRNAME
166+
/ _require_safe_agent_id(agent_id)
167+
)
136168

137169

138170
def _sentinel_path(agent_id: str, install_root: Optional[Path] = None) -> Path:
@@ -184,6 +216,23 @@ def read_sentinel(
184216
except (OSError, json.JSONDecodeError) as exc:
185217
logger.warning("installer: unreadable sentinel %s: %s", path, exc)
186218
return None
219+
executable = data.get("executable", "")
220+
# The sentinel is written from remote publish metadata; an executable
221+
# with path separators could point health checks / the daemon outside
222+
# the install dir. Treat it like any other corrupt sentinel.
223+
if executable and (
224+
not isinstance(executable, str)
225+
or "/" in executable
226+
or "\\" in executable
227+
or Path(executable).name != executable
228+
):
229+
logger.warning(
230+
"installer: sentinel %s has unsafe executable %r (must be a bare "
231+
"filename); treating agent as not installed — re-install it",
232+
path,
233+
executable,
234+
)
235+
return None
187236
return InstalledAgent(
188237
id=data.get("id", agent_id),
189238
version=data.get("version", ""),
@@ -207,6 +256,11 @@ def list_installed(install_root: Optional[Path] = None) -> Dict[str, InstalledAg
207256
for child in sorted(root.iterdir()):
208257
if not child.is_dir() or child.name == BACKUP_DIRNAME:
209258
continue
259+
if not _AGENT_ID_SAFE_RE.match(child.name):
260+
# Not a directory this installer could have created — skip it
261+
# rather than crash the listing on stray junk in the root.
262+
logger.warning("installer: ignoring non-agent directory %s", child)
263+
continue
210264
if not (child / SENTINEL_NAME).exists():
211265
continue
212266
installed = read_sentinel(child.name, install_root)

src/gaia/hub/lifecycle.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,16 @@ class LifecycleError(RuntimeError):
6666

6767

6868
def config_path(agent_id: str, install_root: Optional[Path] = None) -> Path:
69-
"""Path of the per-agent ``config.json`` (``~/.gaia/agents/<id>/config.json``)."""
70-
return installer_mod.agent_install_dir(agent_id, install_root) / CONFIG_NAME
69+
"""Path of the per-agent ``config.json`` (``~/.gaia/agents/<id>/config.json``).
70+
71+
Raises:
72+
LifecycleError: If *agent_id* is not a safe single path component
73+
(path-traversal shaped ids are rejected by the installer).
74+
"""
75+
try:
76+
return installer_mod.agent_install_dir(agent_id, install_root) / CONFIG_NAME
77+
except installer_mod.InstallError as exc:
78+
raise LifecycleError(str(exc)) from exc
7179

7280

7381
# ---------------------------------------------------------------------------

src/gaia/ui/routers/hub.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,14 +298,20 @@ async def set_agent_config(agent_id: str, body: ConfigRequest):
298298
async def agent_health(agent_id: str, request: Request):
299299
"""Health check: does the installed agent load + its entry point resolve?"""
300300
registry = _registry(request)
301-
return lifecycle_mod.health_check(agent_id, registry=registry).to_dict()
301+
try:
302+
return lifecycle_mod.health_check(agent_id, registry=registry).to_dict()
303+
except (installer_mod.InstallError, lifecycle_mod.LifecycleError) as exc:
304+
raise HTTPException(status_code=400, detail=str(exc)) from exc
302305

303306

304307
@router.get("/api/agents/{agent_id}/status")
305308
async def agent_status(agent_id: str, request: Request):
306309
"""Aggregated status: installed version, health, config summary."""
307310
registry = _registry(request)
308-
return lifecycle_mod.status(agent_id, registry=registry).to_dict()
311+
try:
312+
return lifecycle_mod.status(agent_id, registry=registry).to_dict()
313+
except (installer_mod.InstallError, lifecycle_mod.LifecycleError) as exc:
314+
raise HTTPException(status_code=400, detail=str(exc)) from exc
309315

310316

311317
# ---------------------------------------------------------------------------

src/gaia/ui/utils.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,8 @@ def safe_open_document(
453453
454454
Validates that the path:
455455
- Is not a symlink (400 if symlink — checked before resolving)
456-
- Is within the user home directory (403 if not)
456+
- Is within the user home directory, both lexically and after resolving
457+
every symlinked component (403 if not)
457458
- Has an allowed extension (400 if not)
458459
- Exists and is a regular file (404 / 400 if not)
459460
@@ -476,7 +477,19 @@ def safe_open_document(
476477
detail=f"Access denied: path must be within home directory ({home})",
477478
)
478479

479-
# 2. Reject symlinks before resolving — lstat doesn't follow symlinks
480+
# 2. Physical containment: the lexical check above can be defeated by an
481+
# intermediate symlinked directory inside home that points outside
482+
# (O_NOFOLLOW below only guards the FINAL path component). realpath
483+
# resolves every component; require the result to stay under home.
484+
real = os.path.realpath(str(raw))
485+
home_prefix = str(home).rstrip(os.sep) + os.sep
486+
if not real.startswith(home_prefix):
487+
raise HTTPException(
488+
status_code=403,
489+
detail=f"Access denied: path must be within home directory ({home})",
490+
)
491+
492+
# 3. Reject symlinks before resolving — lstat doesn't follow symlinks
480493
try:
481494
lst = os.lstat(str(raw))
482495
if stat.S_ISLNK(lst.st_mode):
@@ -489,17 +502,17 @@ def safe_open_document(
489502
except OSError as exc:
490503
raise HTTPException(status_code=400, detail=f"Cannot stat file: {exc}")
491504

492-
# 3. Now resolve (safe: we already confirmed it's not a symlink)
493-
resolved = raw.resolve()
505+
# 4. Open exactly the path we containment-checked (fully resolved)
506+
resolved = Path(real)
494507

495-
# 4. Extension must be allowed
508+
# 5. Extension must be allowed
496509
if resolved.suffix.lower() not in ALLOWED_EXTENSIONS:
497510
raise HTTPException(
498511
status_code=400,
499512
detail=f"File type not allowed: {resolved.suffix}. Allowed: {sorted(ALLOWED_EXTENSIONS)}",
500513
)
501514

502-
# 5. Open safely — use O_NOFOLLOW on POSIX to reject symlinks at kernel level
515+
# 6. Open safely — use O_NOFOLLOW on POSIX to reject symlinks at kernel level
503516
flags = os.O_RDONLY
504517
if hasattr(os, "O_NOFOLLOW"):
505518
flags |= os.O_NOFOLLOW # POSIX only; raises OSError(ELOOP) on symlinks
@@ -522,7 +535,7 @@ def safe_open_document(
522535

523536
try:
524537
st = os.fstat(fd)
525-
# 6. Must be a regular file (not dir, device, etc.)
538+
# 7. Must be a regular file (not dir, device, etc.)
526539
if not stat.S_ISREG(st.st_mode):
527540
raise HTTPException(
528541
status_code=400,

tests/unit/chat/ui/test_toctou.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,33 @@ def test_safe_open_rejects_symlink(self, home_tmp_dir):
4848
or "symbolic" in exc_info.value.detail.lower()
4949
)
5050

51+
def test_safe_open_rejects_symlinked_directory_escape(self, home_tmp_dir):
52+
"""An intermediate symlinked dir inside home must not open files outside.
53+
54+
The lexical (abspath) home check passes for
55+
``~/.gaia_test_toctou/esc/secret.txt`` while the physical path lives
56+
outside home — the realpath containment check must reject it with 403.
57+
O_NOFOLLOW alone cannot catch this: it only guards the final component.
58+
"""
59+
import tempfile
60+
61+
outside_dir = Path(tempfile.mkdtemp(prefix="gaia_toctou_outside_"))
62+
try:
63+
secret = outside_dir / "secret.txt"
64+
secret.write_text("outside-home content")
65+
escape_link = home_tmp_dir / "esc"
66+
try:
67+
escape_link.symlink_to(outside_dir, target_is_directory=True)
68+
except OSError:
69+
pytest.skip("Symlink creation requires elevated privileges on Windows")
70+
attack_path = escape_link / "secret.txt"
71+
with pytest.raises(HTTPException) as exc_info:
72+
with safe_open_document(str(attack_path)):
73+
pass
74+
assert exc_info.value.status_code == 403
75+
finally:
76+
shutil.rmtree(outside_dir, ignore_errors=True)
77+
5178
def test_safe_open_rejects_missing_file(self, home_tmp_dir):
5279
"""Non-existent file must return 404."""
5380
missing = home_tmp_dir / "nonexistent.txt"

tests/unit/test_file_watcher.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,41 @@ def test_compute_file_hash_with_path_object(self):
8686
finally:
8787
temp_path.unlink()
8888

89+
def test_compute_file_hash_allowed_dir_rejects_traversal(self, tmp_path):
90+
"""A path outside allowed_dir must be rejected, including via '../'."""
91+
allowed_dir = tmp_path / "watch"
92+
allowed_dir.mkdir()
93+
outside_dir = tmp_path / "outside"
94+
outside_dir.mkdir()
95+
secret = outside_dir / "secret.txt"
96+
secret.write_bytes(b"do not hash me")
97+
98+
# Direct outside path
99+
assert compute_file_hash(str(secret), allowed_dir=str(allowed_dir)) is None
100+
101+
# Traversal from within allowed_dir back out to the same file
102+
traversal_path = allowed_dir / ".." / "outside" / "secret.txt"
103+
assert (
104+
compute_file_hash(str(traversal_path), allowed_dir=str(allowed_dir)) is None
105+
)
106+
107+
def test_compute_file_hash_allowed_dir_rejects_absolute_escape(self, tmp_path):
108+
"""An absolute path outside allowed_dir is rejected, not silently hashed."""
109+
allowed_dir = tmp_path / "watch"
110+
allowed_dir.mkdir()
111+
assert compute_file_hash("/etc/passwd", allowed_dir=str(allowed_dir)) is None
112+
113+
def test_compute_file_hash_allowed_dir_accepts_contained_path(self, tmp_path):
114+
"""A path genuinely inside allowed_dir still hashes normally."""
115+
allowed_dir = tmp_path / "watch"
116+
allowed_dir.mkdir()
117+
f = allowed_dir / "form.pdf"
118+
f.write_bytes(b"intake form bytes")
119+
120+
result = compute_file_hash(str(f), allowed_dir=str(allowed_dir))
121+
assert result is not None
122+
assert len(result) == 64
123+
89124
def test_compute_bytes_hash_returns_sha256(self):
90125
"""Test that compute_bytes_hash returns a valid SHA-256 hash."""
91126
result = compute_bytes_hash(b"test bytes")

0 commit comments

Comments
 (0)