diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ca4e5b02ec0..4f5aa98b2e4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1186,6 +1186,20 @@ _FS_READDIR_HIDDEN = { "target", "venv", } + +# Filenames that must never be listed, read, or downloaded through the +# managed-files API. These typically contain credentials (API keys, tokens) +# and exposing them through the dashboard file browser is a security leak — +# see issue #57505. +_SENSITIVE_FILENAMES: frozenset[str] = frozenset({ + ".env", + ".env.local", + ".env.production", + ".env.development", + ".env.staging", + ".env.test", + ".env.backup", +}) _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 @@ -1616,7 +1630,11 @@ async def list_managed_files(request: Request, path: Optional[str] = None): raise HTTPException(status_code=400, detail="Path is not a directory") try: - entries = [_managed_file_entry(policy, child) for child in target.iterdir()] + entries = [ + _managed_file_entry(policy, child) + for child in target.iterdir() + if child.name not in _SENSITIVE_FILENAMES + ] except PermissionError: raise HTTPException(status_code=403, detail="Directory is not readable") except OSError as exc: @@ -1642,6 +1660,8 @@ async def read_managed_file(request: Request, path: str): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") + if target.name in _SENSITIVE_FILENAMES: + raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: size = target.stat().st_size @@ -1684,6 +1704,8 @@ async def download_managed_file(request: Request, path: str): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") + if target.name in _SENSITIVE_FILENAMES: + raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: size = target.stat().st_size diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index b295f0ab998..b72e75c3fd9 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -488,3 +488,48 @@ def test_stream_upload_cleans_temp_on_cancellation(forced_files_client): # ... and no .upload temp file was left behind. leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name] assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}" + + +def test_sensitive_env_files_hidden_from_listing(forced_files_client): + """Regression test for #57505: .env files must not appear in directory listings.""" + client, root = forced_files_client + + # Create a regular file and a .env file. + root.mkdir(parents=True, exist_ok=True) + regular = root / "config.txt" + regular.write_text("safe content") + env_file = root / ".env" + env_file.write_text("SECRET_KEY=abc123") + env_local = root / ".env.local" + env_local.write_text("LOCAL_SECRET=def456") + + listing = client.get("/api/files", params={"path": str(root)}) + assert listing.status_code == 200 + names = [e["name"] for e in listing.json()["entries"]] + assert "config.txt" in names + assert ".env" not in names + assert ".env.local" not in names + + +def test_sensitive_env_files_blocked_read(forced_files_client): + """Regression test for #57505: .env files must not be readable.""" + client, root = forced_files_client + + root.mkdir(parents=True, exist_ok=True) + env_file = root / ".env" + env_file.write_text("SECRET_KEY=abc123") + + resp = client.get("/api/files/read", params={"path": str(env_file)}) + assert resp.status_code == 403 + + +def test_sensitive_env_files_blocked_download(forced_files_client): + """Regression test for #57505: .env files must not be downloadable.""" + client, root = forced_files_client + + root.mkdir(parents=True, exist_ok=True) + env_file = root / ".env" + env_file.write_text("SECRET_KEY=abc123") + + resp = client.get("/api/files/download", params={"path": str(env_file)}) + assert resp.status_code == 403