fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.
This commit is contained in:
dadachann
2026-07-30 04:46:31 +00:00
parent 6b8838a308
commit 0e30b32889
2 changed files with 9 additions and 7 deletions
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
token: str,
) -> None:
) -> RequestContext:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
@@ -168,6 +168,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
return current_context
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await run_in_workspace_uow(
+7 -6
View File
@@ -495,18 +495,19 @@ class WorkspaceCollaborationService:
return membership
token_digest = hash_invitation_token(token)
async with self._invitation_lock(token_digest):
lock_key = f"{workspace_uuid}:{token_digest}"
async with self._invitation_lock(lock_key):
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, token_digest: str):
"""Serialize one token while retaining only active lock entries."""
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(token_digest)
entry = self._invitation_locks.get(lock_key)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[token_digest] = entry
self._invitation_locks[lock_key] = entry
entry.users += 1
await entry.lock.acquire()
@@ -517,7 +518,7 @@ class WorkspaceCollaborationService:
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(token_digest, None)
self._invitation_locks.pop(lock_key, None)
async def revoke_invitation(
self,