security(dashboard): widen managed-files sensitive-filename guard past .env

_is_sensitive_filename() only blocked .env / .env.<suffix>, but the
dashboard Files tab's managed root is operator-configurable and, per the
docker-mount scenario #57505 was filed against, can point directly at
HERMES_HOME — where the canonical credential stores enforced elsewhere
in the codebase (gateway.platforms.base._ROOT_CREDENTIAL_FILES,
agent.file_safety.get_read_block_error) all live: auth.json, OAuth
token stores, webhook HMAC secrets, the Bitwarden disk cache. None of
those basenames were blocked, so the Files tab could still list, read,
and download them. .envrc (direnv) also slipped past the old check
since it doesn't equal ".env" or start with ".env.".

Widen the basename set to mirror both existing guards so the dashboard
doesn't lag behind them.
This commit is contained in:
srojk34 2026-07-03 19:21:31 +03:00 committed by kshitijk4poor
parent 09693cd3a3
commit 43ec69cef3
2 changed files with 78 additions and 6 deletions

View file

@ -1194,15 +1194,41 @@ _FS_READDIR_HIDDEN = {
# 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.
def _is_sensitive_filename(name: str) -> bool:
"""Return True for ``.env`` and any ``.env.<suffix>`` variant.
# see issue #57505. The set mirrors the two canonical credential guards
# elsewhere in the codebase (agent.file_safety.get_read_block_error and
# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab
# doesn't lag behind them — an operator can point the managed root at
# HERMES_HOME itself, at which point every one of these basenames is a live
# secret store sitting in the browsable tree.
_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({
"auth.json",
"auth.lock",
"credentials",
"config.yaml",
".anthropic_oauth.json",
"google_token.json",
"google_oauth_pending.json",
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
})
Case-insensitive so ``.ENV`` / ``.Env.local`` on case-insensitive
filesystems (macOS/Windows mounts) can't slip past the guard.
def _is_sensitive_filename(name: str) -> bool:
"""Return True for a filename the managed-files API must never expose.
Covers ``.env`` / ``.env.<suffix>`` / ``.envrc`` variants plus the
canonical Hermes credential-store basenames (see
``_SENSITIVE_MANAGED_FILE_BASENAMES`` above).
Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on
case-insensitive filesystems (macOS/Windows mounts) can't slip past
the guard.
"""
lowered = name.lower()
return lowered == ".env" or lowered.startswith(".env.")
if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc":
return True
return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024

View file

@ -560,3 +560,49 @@ def test_sensitive_env_case_insensitive_blocked(forced_files_client):
p.write_text("SECRET=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
def test_envrc_blocked(forced_files_client):
"""Regression: .envrc (direnv) is a distinct basename from .env.<suffix> and
was not caught by the old ``== ".env" or startswith(".env.")`` check."""
client, root = forced_files_client
root.mkdir(parents=True, exist_ok=True)
p = root / ".envrc"
p.write_text("export SECRET_KEY=abc123")
listing = client.get("/api/files", params={"path": str(root)})
assert ".envrc" not in [e["name"] for e in listing.json()["entries"]]
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
def test_other_credential_store_basenames_blocked(forced_files_client):
"""Regression: the managed-files guard must cover the same credential
basenames as gateway.platforms.base._ROOT_CREDENTIAL_FILES and
agent.file_safety.get_read_block_error, not just .env an operator can
point the managed root at HERMES_HOME itself (#57505), which contains
all of these live secret stores."""
client, root = forced_files_client
root.mkdir(parents=True, exist_ok=True)
for name in (
"auth.json",
"auth.lock",
"credentials",
"config.yaml",
".anthropic_oauth.json",
"google_token.json",
"google_oauth_pending.json",
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
):
p = root / name
p.write_text("SECRET=abc123")
assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403, name
assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403, name
listing = client.get("/api/files", params={"path": str(root)})
names = [e["name"] for e in listing.json()["entries"]]
assert names == []