134 lines
5 KiB
Python
134 lines
5 KiB
Python
|
|
"""Regression test for the #4067 cross-profile import_cli boundary.
|
||
|
|
|
||
|
|
The all-profiles session enumeration (#4067) must NOT relax the cross-profile
|
||
|
|
isolation boundary on the import_cli refresh path. Both release-gate advisors
|
||
|
|
(Codex CORE + Opus) reproduced the same leak on PR head b16586f0: the
|
||
|
|
already-imported branch of `_handle_session_import_cli` forced
|
||
|
|
`allow_all_profiles=True` whenever the (globally-stored) session carried a
|
||
|
|
profile, and had no `_session_visible_to_active_profile` gate — so an
|
||
|
|
unqualified request from profile A could read/refresh a session imported under
|
||
|
|
profile B's live `state.db` (Codex returned the foreign transcript + a planted
|
||
|
|
secret on an unqualified request).
|
||
|
|
|
||
|
|
This pins the fix: the existing-session branch is profile-scoped exactly like
|
||
|
|
the /api/session detail and /api/session/export endpoints.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import io
|
||
|
|
|
||
|
|
import api.routes as routes
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeHandler:
|
||
|
|
def __init__(self, cookie_profile: str | None = None):
|
||
|
|
self.status = None
|
||
|
|
self.headers = {}
|
||
|
|
if cookie_profile is not None:
|
||
|
|
self.headers["Cookie"] = f"hermes_profile={cookie_profile}"
|
||
|
|
self.wfile = io.BytesIO()
|
||
|
|
|
||
|
|
def send_response(self, status):
|
||
|
|
self.status = status
|
||
|
|
|
||
|
|
def send_header(self, key, value):
|
||
|
|
self.headers[key] = value
|
||
|
|
|
||
|
|
def end_headers(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _capture(monkeypatch):
|
||
|
|
cap = {}
|
||
|
|
monkeypatch.setattr(routes, "j", lambda h, o: (cap.__setitem__("ok", o), True)[1])
|
||
|
|
monkeypatch.setattr(
|
||
|
|
routes,
|
||
|
|
"bad",
|
||
|
|
lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1],
|
||
|
|
)
|
||
|
|
return cap
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeSession:
|
||
|
|
"""Minimal stand-in for a globally-stored Session imported under a profile."""
|
||
|
|
|
||
|
|
def __init__(self, sid, profile):
|
||
|
|
self.session_id = sid
|
||
|
|
self.profile = profile
|
||
|
|
self.messages = [{"role": "user", "content": "FOREIGN_SECRET"}]
|
||
|
|
self.source_tag = "cli"
|
||
|
|
self.raw_source = "cli"
|
||
|
|
self.session_source = "cli"
|
||
|
|
self.source_label = "CLI"
|
||
|
|
self.parent_session_id = None
|
||
|
|
|
||
|
|
def compact(self):
|
||
|
|
return {"id": self.session_id, "profile": self.profile}
|
||
|
|
|
||
|
|
def save(self, touch_updated_at=False):
|
||
|
|
raise AssertionError("must not save/refresh a foreign-profile session")
|
||
|
|
|
||
|
|
|
||
|
|
def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch):
|
||
|
|
"""Unqualified request (active profile=default) for a session stored under a
|
||
|
|
foreign profile must 404 — not read or refresh the foreign session."""
|
||
|
|
foreign = _FakeSession("foreign_existing_001", "other")
|
||
|
|
monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: foreign))
|
||
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
||
|
|
# If the gate fails open, these would be reached — make them loud.
|
||
|
|
monkeypatch.setattr(
|
||
|
|
routes, "get_cli_session_messages",
|
||
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("foreign read attempted")),
|
||
|
|
)
|
||
|
|
cap = _capture(monkeypatch)
|
||
|
|
|
||
|
|
routes._handle_session_import_cli(
|
||
|
|
_FakeHandler(cookie_profile="default"),
|
||
|
|
{"session_id": "foreign_existing_001"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert "bad" in cap, f"expected 404, got {cap}"
|
||
|
|
assert cap["bad"][1] == 404
|
||
|
|
|
||
|
|
|
||
|
|
def test_import_cli_existing_same_profile_still_refreshes(monkeypatch):
|
||
|
|
"""A session stored under the active profile is still served/refreshed."""
|
||
|
|
own = _FakeSession("own_001", "default")
|
||
|
|
own.save = lambda touch_updated_at=False: None # allow refresh
|
||
|
|
monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: own))
|
||
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
||
|
|
monkeypatch.setattr(routes, "_resolve_cli_import_metadata", lambda *a, **k: {})
|
||
|
|
monkeypatch.setattr(routes, "get_cli_session_messages", lambda *a, **k: [])
|
||
|
|
cap = _capture(monkeypatch)
|
||
|
|
|
||
|
|
routes._handle_session_import_cli(
|
||
|
|
_FakeHandler(cookie_profile="default"),
|
||
|
|
{"session_id": "own_001"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert "ok" in cap, f"expected success, got {cap}"
|
||
|
|
assert cap["ok"]["session"]["id"] == "own_001"
|
||
|
|
|
||
|
|
|
||
|
|
def test_import_cli_all_profiles_requires_matching_profile(monkeypatch):
|
||
|
|
"""An explicit all_profiles import for a foreign session must still match the
|
||
|
|
requested profile — a mismatched profile 404s rather than reading."""
|
||
|
|
foreign = _FakeSession("foreign_002", "other")
|
||
|
|
monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: foreign))
|
||
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
||
|
|
monkeypatch.setattr(
|
||
|
|
routes, "get_cli_session_messages",
|
||
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("foreign read attempted")),
|
||
|
|
)
|
||
|
|
cap = _capture(monkeypatch)
|
||
|
|
|
||
|
|
# all_profiles=1 but requested profile "haku" != stored "other" → 404.
|
||
|
|
routes._handle_session_import_cli(
|
||
|
|
_FakeHandler(cookie_profile="default"),
|
||
|
|
{"session_id": "foreign_002", "all_profiles": 1, "profile": "haku"},
|
||
|
|
)
|
||
|
|
|
||
|
|
assert "bad" in cap, f"expected 404, got {cap}"
|
||
|
|
assert cap["bad"][1] == 404
|