diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4f5aa98b2..217e7723d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1191,15 +1191,9 @@ _FS_READDIR_HIDDEN = { # 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", -}) +def _is_sensitive_filename(name: str) -> bool: + """Return True for ``.env`` and any ``.env.`` variant.""" + return name == ".env" or name.startswith(".env.") _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 @@ -1633,7 +1627,7 @@ async def list_managed_files(request: Request, path: Optional[str] = None): entries = [ _managed_file_entry(policy, child) for child in target.iterdir() - if child.name not in _SENSITIVE_FILENAMES + if not _is_sensitive_filename(child.name) ] except PermissionError: raise HTTPException(status_code=403, detail="Directory is not readable") @@ -1660,7 +1654,7 @@ 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: + if _is_sensitive_filename(target.name): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: @@ -1704,7 +1698,7 @@ 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: + if _is_sensitive_filename(target.name): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index b72e75c3f..8bc40680f 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -494,7 +494,7 @@ 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. + # Create a regular file and .env variants including shorthand suffixes. root.mkdir(parents=True, exist_ok=True) regular = root / "config.txt" regular.write_text("safe content") @@ -502,6 +502,8 @@ def test_sensitive_env_files_hidden_from_listing(forced_files_client): env_file.write_text("SECRET_KEY=abc123") env_local = root / ".env.local" env_local.write_text("LOCAL_SECRET=def456") + env_prod = root / ".env.prod" + env_prod.write_text("PROD_SECRET=ghi789") listing = client.get("/api/files", params={"path": str(root)}) assert listing.status_code == 200 @@ -509,6 +511,7 @@ def test_sensitive_env_files_hidden_from_listing(forced_files_client): assert "config.txt" in names assert ".env" not in names assert ".env.local" not in names + assert ".env.prod" not in names def test_sensitive_env_files_blocked_read(forced_files_client): @@ -533,3 +536,15 @@ def test_sensitive_env_files_blocked_download(forced_files_client): resp = client.get("/api/files/download", params={"path": str(env_file)}) assert resp.status_code == 403 + + +def test_sensitive_env_suffix_variants_blocked(forced_files_client): + """Regression: .env. shorthand variants (e.g. .env.prod) must also be blocked.""" + client, root = forced_files_client + + root.mkdir(parents=True, exist_ok=True) + for suffix in ("prod", "dev", "staging.local", "ci"): + p = root / f".env.{suffix}" + p.write_text(f"SECRET_{suffix}=abc123") + assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403 + assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403