mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
09693cd3a3
commit
43ec69cef3
2 changed files with 78 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue