From d67c9d2a285059b89a5efb6c5e3b3295b176a205 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 13 Sep 2026 23:20:01 -0400 Subject: [PATCH] fix(dashboard): apply the sensitive-path guard to fs_read_text and fs_list _is_sensitive_path documents itself as the read-side guard for list/read/ download (#57505), but only fs_read_data_url and fs_download called it. fs_read_text returned .env / auth.json / mcp-tokens/* contents to an authenticated dashboard session and fs_list enumerated them. Move the check into _fs_regular_file, the resolver every fs reader goes through, and drop the two per-handler copies. fs_list filters on the same predicate alongside _FS_READDIR_HIDDEN. Reported-by: Brian Grablin --- hermes_cli/web_routers/files.py | 8 +++----- tests/hermes_cli/test_web_server_fs.py | 27 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py index 68c7e1ed38..296b88578c 100644 --- a/hermes_cli/web_routers/files.py +++ b/hermes_cli/web_routers/files.py @@ -169,6 +169,8 @@ def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: raise HTTPException(status_code=400, detail="Path points to a directory") if not stat.S_ISREG(st.st_mode): raise HTTPException(status_code=400, detail="Only regular files can be read") + if _is_sensitive_path(target): + raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") return target, st @@ -611,7 +613,7 @@ async def fs_list(path: str): entries = [] with os.scandir(target) as scan: for entry in scan: - if entry.name in _FS_READDIR_HIDDEN: + if entry.name in _FS_READDIR_HIDDEN or _is_sensitive_path(Path(entry.path)): continue entries.append({ "name": entry.name, @@ -710,8 +712,6 @@ async def fs_read_data_url( ): from hermes_cli.web_server import _FS_DATA_URL_MAX_BYTES target, st = _fs_regular_file(await _fs_download_path(path, profile, session_id)) - if _is_sensitive_path(target): - raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") if st.st_size > _FS_DATA_URL_MAX_BYTES: raise HTTPException(status_code=413, detail="File too large") encoded = base64.b64encode(_fs_read_bytes(target)).decode("ascii") @@ -723,8 +723,6 @@ async def fs_download( path: str, profile: Optional[str] = None, session_id: Optional[str] = None, ): target, _st = _fs_regular_file(await _fs_download_path(path, profile, session_id)) - if _is_sensitive_path(target): - raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") return FileResponse( path=str(target), media_type=_fs_mime_type(target), diff --git a/tests/hermes_cli/test_web_server_fs.py b/tests/hermes_cli/test_web_server_fs.py index a7a0a37a20..74656fe517 100644 --- a/tests/hermes_cli/test_web_server_fs.py +++ b/tests/hermes_cli/test_web_server_fs.py @@ -77,6 +77,33 @@ def test_fs_download_rejects_sensitive_files(client, tmp_path): assert response.status_code == 403 +@pytest.mark.parametrize("endpoint", ["/api/fs/read-text", "/api/fs/read-data-url", "/api/fs/download"]) +@pytest.mark.parametrize("relative", [".env", "auth.json", "mcp-tokens/github.json"]) +def test_fs_readers_reject_sensitive_paths(client, tmp_path, endpoint, relative): + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("SECRET=1") + + response = client.get(endpoint, params={"path": str(target)}) + + assert response.status_code == 403 + assert "SECRET" not in response.text + + +def test_fs_list_hides_sensitive_entries(client, tmp_path): + root = tmp_path / "project" + root.mkdir() + (root / ".env").write_text("SECRET=1") + (root / "auth.json").write_text("{}") + (root / "mcp-tokens").mkdir() + (root / "notes.txt").write_text("ok") + + response = client.get("/api/fs/list", params={"path": str(root)}) + + assert response.status_code == 200 + assert [entry["name"] for entry in response.json()["entries"]] == ["notes.txt"] + + def test_fs_endpoints_require_auth(tmp_path): client = TestClient(web_server.app) target = tmp_path / "secret.txt"