harden(#4589): protect HERMES_WEBUI_ISOLATED_PROFILE from profile-.env override
Codex gate (CORE, reproduced): _reload_dotenv() copies a profile's .env into os.environ, so a contained user could set HERMES_WEBUI_ISOLATED_PROFILE=0 in their own profile .env and silently escape isolation. Fix: _PROTECTED_ENV_KEYS — _reload_dotenv refuses to override operator/deployment keys from a profile .env (logs a warning). + regression test proving .env can't disable isolation.
This commit is contained in:
1 parent
17eb82f087
commit
cb7efedc38
2 files changed
+67
-1
No files matched your search
+25
-1
@@ -125,9 +125,15 @@ def _unwrap_profile_home_to_base(home: Path) -> Path:
|
||||
return home
|
||||
|
||||
|
||||
# Env keys a pinned profile's .env may NOT override via _reload_dotenv() — these
|
||||
# are operator/deployment-level postures, not per-profile toggles. Letting a
|
||||
# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user
|
||||
# escape isolation (#4589).
|
||||
_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'})
|
||||
|
||||
|
||||
def _isolated_profile_opt_in() -> bool:
|
||||
"""Return True only when isolated single-profile mode is EXPLICITLY enabled.
|
||||
|
||||
Isolated mode is an intentional multi-user deployment posture (each user is
|
||||
pinned to one profile and cross-profile operations are rejected). It must be
|
||||
opted into with ``HERMES_WEBUI_ISOLATED_PROFILE`` — it is NEVER inferred from
|
||||
@@ -138,6 +144,14 @@ def _isolated_profile_opt_in() -> bool:
|
||||
profile switching for ordinary single-user deployments (#4586).
|
||||
|
||||
Accepts the usual truthy values; default (unset/empty/falsey) is OFF.
|
||||
|
||||
Security: this reads the operator-set env var live, but a pinned profile's
|
||||
``.env`` can NEVER override it — ``_reload_dotenv()`` explicitly refuses to
|
||||
copy ``HERMES_WEBUI_ISOLATED_PROFILE`` from a profile ``.env`` into
|
||||
``os.environ`` (see ``_PROTECTED_ENV_KEYS``). Otherwise a contained user could
|
||||
set ``HERMES_WEBUI_ISOLATED_PROFILE=0`` in their own profile ``.env`` and
|
||||
silently escape isolation (#4589). The opt-in is an operator/deployment
|
||||
posture, never a per-profile-file toggle.
|
||||
"""
|
||||
return os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() in (
|
||||
'1', 'true', 'yes', 'on',
|
||||
@@ -1061,6 +1075,16 @@ def _reload_dotenv(home: Path):
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k and v:
|
||||
# Operator/deployment-level keys are never overridable by a
|
||||
# profile's own .env (#4589 — prevents a contained user from
|
||||
# disabling their isolation via HERMES_WEBUI_ISOLATED_PROFILE=0).
|
||||
if k in _PROTECTED_ENV_KEYS:
|
||||
logger.warning(
|
||||
"Ignoring protected key %s in profile .env %s; "
|
||||
"operator/deployment env takes precedence",
|
||||
k, env_path,
|
||||
)
|
||||
continue
|
||||
os.environ[k] = v
|
||||
loaded_keys.add(k)
|
||||
_loaded_profile_env_keys = loaded_keys
|
||||
|
||||
@@ -155,3 +155,45 @@ class TestIssue4586ExplicitOptInStillWorks:
|
||||
assert _is_isolated_profile_mode() is False, (
|
||||
f"falsey flag {flag!r} must not engage isolated mode"
|
||||
)
|
||||
|
||||
|
||||
class TestIssue4589ProfileEnvCannotDisableIsolation:
|
||||
"""#4589: a pinned profile's own .env must NOT be able to turn isolation OFF.
|
||||
|
||||
_reload_dotenv() copies a profile's .env into os.environ. Before the fix, a
|
||||
contained user could put HERMES_WEBUI_ISOLATED_PROFILE=0 in their profile .env
|
||||
and escape isolation. The operator flag is now protected from .env override.
|
||||
"""
|
||||
|
||||
def test_profile_env_cannot_clear_isolated_flag(self, named_profile_home):
|
||||
from api.profiles import _reload_dotenv, _PROTECTED_ENV_KEYS
|
||||
|
||||
assert "HERMES_WEBUI_ISOLATED_PROFILE" in _PROTECTED_ENV_KEYS
|
||||
|
||||
active = named_profile_home["active"]
|
||||
# Operator opts the deployment into isolation.
|
||||
with mock.patch.dict(os.environ, {"HERMES_HOME": str(active),
|
||||
"HERMES_WEBUI_ISOLATED_PROFILE": "1"}, clear=False):
|
||||
with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)):
|
||||
assert _is_isolated_profile_mode() is True
|
||||
|
||||
# The contained user plants HERMES_WEBUI_ISOLATED_PROFILE=0 in their .env.
|
||||
(active / ".env").write_text(
|
||||
"HERMES_WEBUI_ISOLATED_PROFILE=0\nSOME_OTHER_KEY=ok\n", encoding="utf-8"
|
||||
)
|
||||
try:
|
||||
_reload_dotenv(active)
|
||||
# The protected flag must NOT have been overridden → still isolated.
|
||||
assert os.environ.get("HERMES_WEBUI_ISOLATED_PROFILE") == "1", (
|
||||
"profile .env must not override the operator isolation flag"
|
||||
)
|
||||
assert _is_isolated_profile_mode() is True, (
|
||||
"#4589: a profile .env must not be able to disable isolation"
|
||||
)
|
||||
# Non-protected keys still load normally.
|
||||
assert os.environ.get("SOME_OTHER_KEY") == "ok"
|
||||
finally:
|
||||
try:
|
||||
os.environ.pop("SOME_OTHER_KEY", None)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in new issue
Block a user