fix(dashboard): block .env files from managed-files API

The dashboard Files tab could list, read, and download .env files
containing API keys when running with a bind-mounted Hermes home
directory (e.g. docker run -v ~/.hermes:/opt/data).

Add _SENSITIVE_FILENAMES frozenset and filter these from
list_managed_files(), read_managed_file(), and download_managed_file().
Return 403 for direct read/download attempts on sensitive files.

Fixes #57505
This commit is contained in:
liuhao1024 2026-07-03 11:55:08 +08:00 committed by Teknium
parent 16332af60b
commit bc55c201c7
2 changed files with 68 additions and 1 deletions

View file

@ -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

View file

@ -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