fix(webui): prevent stale workspace scope restore
This commit is contained in:
@@ -754,6 +754,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not content.strip() and not media_paths:
|
if not content.strip() and not media_paths:
|
||||||
await self._send_event(connection, "error", detail="missing content")
|
await self._send_event(connection, "error", detail="missing content")
|
||||||
return
|
return
|
||||||
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
|
self._attach(connection, cid)
|
||||||
|
await self._hydrate_after_subscribe(cid)
|
||||||
|
|
||||||
|
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_message(
|
lambda: self._workspaces.scope_for_message(
|
||||||
@@ -767,9 +772,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if scope is None:
|
if scope is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
|
||||||
self._attach(connection, cid)
|
|
||||||
await self._hydrate_after_subscribe(cid)
|
|
||||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
if envelope.get("webui") is True:
|
if envelope.get("webui") is True:
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
|
|||||||
@@ -786,6 +786,76 @@ async def test_remote_webui_scope_allows_access_reduction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_access_reduction_rejects_stale_in_flight_message_scope(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
|
default_workspace = tmp_path / "default"
|
||||||
|
default_workspace.mkdir()
|
||||||
|
sessions = SessionManager(tmp_path / "sessions")
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||||
|
)
|
||||||
|
hydrate_started = asyncio.Event()
|
||||||
|
release_hydrate = asyncio.Event()
|
||||||
|
|
||||||
|
async def blocked_hydrate(_chat_id: str) -> None:
|
||||||
|
hydrate_started.set()
|
||||||
|
await release_hydrate.wait()
|
||||||
|
|
||||||
|
channel._hydrate_after_subscribe = blocked_hydrate
|
||||||
|
message_conn = AsyncMock()
|
||||||
|
message_conn.remote_address = ("203.0.113.8", 50123)
|
||||||
|
settings_conn = AsyncMock()
|
||||||
|
settings_conn.remote_address = ("203.0.113.8", 50124)
|
||||||
|
chat_id = "race-chat"
|
||||||
|
|
||||||
|
message_task = asyncio.create_task(
|
||||||
|
channel._dispatch_envelope(
|
||||||
|
message_conn,
|
||||||
|
"remote-message",
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"content": "hello",
|
||||||
|
"webui": True,
|
||||||
|
"workspace_scope": {
|
||||||
|
"project_path": str(default_workspace),
|
||||||
|
"access_mode": "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await hydrate_started.wait()
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
settings_conn,
|
||||||
|
"remote-settings",
|
||||||
|
{
|
||||||
|
"type": "set_workspace_scope",
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"workspace_scope": {
|
||||||
|
"project_path": str(default_workspace),
|
||||||
|
"access_mode": "restricted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
release_hydrate.set()
|
||||||
|
await message_task
|
||||||
|
|
||||||
|
saved = sessions.read_session_file(f"websocket:{chat_id}")
|
||||||
|
assert saved["metadata"]["workspace_scope"]["access_mode"] == "restricted"
|
||||||
|
payload = json.loads(message_conn.send.await_args.args[0])
|
||||||
|
assert payload["event"] == "error"
|
||||||
|
assert payload["detail"] == "workspace_scope_rejected"
|
||||||
|
bus.publish_inbound.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
|
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
|
||||||
default_workspace = tmp_path / "default"
|
default_workspace = tmp_path / "default"
|
||||||
|
|||||||
Reference in New Issue
Block a user