fix(gateway): isolate deferred platform imports from os.environ leaks
microsoft-teams-apps calls load_dotenv(find_dotenv(usecwd=True)) at import time, which can pull a root-profile .env into every gateway process during plugin_entries() discovery and break profile secret isolation. Snapshot/restore os.environ around deferred loaders, and honor explicit api_server enabled:false the same way _enable_from_env does for other platforms. Fixes #62935
This commit is contained in:
parent
d7522118ef
commit
a98c8eeed1
|
|
@ -29,12 +29,36 @@ Usage (gateway side):
|
|||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
from typing import Any, Awaitable, Callable, Iterator, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _isolated_os_environ() -> Iterator[None]:
|
||||
"""Snapshot and restore ``os.environ`` around a deferred platform import.
|
||||
|
||||
Some third-party SDKs (notably ``microsoft-teams-apps``) call
|
||||
``load_dotenv(find_dotenv(usecwd=True))`` at module import time. That
|
||||
walks up from the process cwd and mutates the process-global environ —
|
||||
leaking root-profile secrets into secondary profiles and bypassing
|
||||
Hermes's own dotenv / secret-scope rules (#62935).
|
||||
|
||||
Deferred loaders are import-and-register only; any environ mutation they
|
||||
cause is treated as an unwanted side effect and discarded.
|
||||
"""
|
||||
saved = dict(os.environ)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(saved)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformEntry:
|
||||
"""Metadata and factory for a single platform adapter."""
|
||||
|
|
@ -205,7 +229,10 @@ class PlatformRegistry:
|
|||
if loader is None:
|
||||
return
|
||||
try:
|
||||
loader()
|
||||
# Isolate environ so import-time dotenv / env mutation cannot leak
|
||||
# across profiles or override explicit YAML disables (#62935).
|
||||
with _isolated_os_environ():
|
||||
loader()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Deferred load of platform '%s' failed: %s",
|
||||
|
|
|
|||
|
|
@ -494,3 +494,41 @@ class TestPluginEnablementGate:
|
|||
)
|
||||
finally:
|
||||
_reg.unregister("myrejectedplat")
|
||||
|
||||
|
||||
|
||||
class TestDeferredLoadEnvironIsolation:
|
||||
"""Deferred platform imports must not leak os.environ mutations (#62935)."""
|
||||
|
||||
def test_deferred_loader_environ_mutations_are_discarded(self, monkeypatch):
|
||||
monkeypatch.delenv("API_SERVER_ENABLED", raising=False)
|
||||
monkeypatch.delenv("LEAKED_FROM_IMPORT", raising=False)
|
||||
assert "API_SERVER_ENABLED" not in os.environ
|
||||
|
||||
reg = PlatformRegistry()
|
||||
|
||||
def _polluting_loader():
|
||||
os.environ["API_SERVER_ENABLED"] = "true"
|
||||
os.environ["LEAKED_FROM_IMPORT"] = "1"
|
||||
entry, _ = TestPlatformRegistry()._make_entry("polluter")
|
||||
reg.register(entry)
|
||||
|
||||
reg.register_deferred("polluter", _polluting_loader)
|
||||
assert reg.get("polluter") is not None
|
||||
assert "API_SERVER_ENABLED" not in os.environ
|
||||
assert "LEAKED_FROM_IMPORT" not in os.environ
|
||||
|
||||
def test_deferred_loader_preserves_preexisting_environ(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KEEP", "alive")
|
||||
reg = PlatformRegistry()
|
||||
|
||||
def _loader():
|
||||
os.environ["HERMES_KEEP"] = "clobbered"
|
||||
os.environ["TEMP_LEAK"] = "1"
|
||||
entry, _ = TestPlatformRegistry()._make_entry("keeper")
|
||||
reg.register(entry)
|
||||
|
||||
reg.register_deferred("keeper", _loader)
|
||||
assert reg.get("keeper") is not None
|
||||
assert os.environ.get("HERMES_KEEP") == "alive"
|
||||
assert "TEMP_LEAK" not in os.environ
|
||||
|
|
|
|||
Loading…
Reference in New Issue