Skip to content

[Security][P1] Fix cross-tenant drive metadata broadcast (#560) - #565

Merged
2witstudios merged 2 commits into
masterfrom
cross-tenant-drives
Feb 12, 2026
Merged

2witstudios merged 2 commits into
masterfrom
cross-tenant-drives

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Security Issue: Any authenticated user could see ALL drive create/update/delete events (including name and slug) regardless of access permissions
  • Root Cause: broadcastDriveEvent() sent to global:drives room with no authorization check
  • Fix: Replace global broadcast with per-user broadcasts to user:${userId}:drives channels

Changes

  • Add getDriveRecipientUserIds() helper that returns owner + all drive members
  • Update broadcastDriveEvent() signature to require recipientUserIds parameter
  • Broadcast to user:${userId}:drives channel instead of global:drives
  • Remove join_global_drives and leave_global_drives handlers from realtime service
  • Update useGlobalDriveSocket hook to only join user-specific channel

Files Modified

Area Files
Core service packages/lib/src/services/drive-member-service.ts
Broadcast utility apps/web/src/lib/websocket/socket-utils.ts
API routes apps/web/src/app/api/drives/route.ts, apps/web/src/app/api/drives/[driveId]/route.ts, + 5 more
Realtime service apps/realtime/src/index.ts
Frontend hook apps/web/src/hooks/useGlobalDriveSocket.ts
AI tools apps/web/src/lib/ai/tools/drive-tools.ts, apps/web/src/lib/ai/tools/page-write-tools.ts
Tests Updated socket-utils tests, added mock to drive route tests

Test plan

  • TypeScript compilation passes
  • All socket-utils tests pass (22 tests)
  • All drive route tests pass (237 tests)
  • Full test suite passes (3752 tests, 14 unrelated DB integration test failures)
  • Manual test: Two users with no shared drives - User A creates drive, User B should NOT receive event
  • Manual test: Add User B to drive, User A updates drive name, User B should receive event

Scalability Note

Current approach: O(members) broadcasts per drive event. Acceptable for typical drive sizes (< 100 members).

Future org-layer optimization: Single broadcast to org:${orgId}:drives room with O(1) broadcast.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Refactoring
    • Migrated from global drive event broadcasts to targeted, user-specific notifications, reducing unnecessary message delivery and improving efficiency.
    • Drive events now only delivered to relevant drive members and owners instead of being sent to all connected users.
    • Enhanced event routing infrastructure to support recipient-based delivery across all drive operations including creation, updates, deletion, and restoration.

Replace global broadcast with per-user broadcasts to prevent cross-tenant
information leakage. Drive create/update/delete events are now only sent
to users who have access to the drive (owner + members).

Changes:
- Add getDriveRecipientUserIds() helper to get owner + members
- Update broadcastDriveEvent() to require recipientUserIds parameter
- Broadcast to user:${userId}:drives channel instead of global:drives
- Remove join_global_drives/leave_global_drives handlers from realtime
- Update useGlobalDriveSocket to only join user-specific channel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 19 minutes and 7 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

The PR transitions drive event broadcasting from a global channel model to a targeted per-user channel model. It introduces recipient-aware broadcasting by adding a new getDriveRecipientUserIds function, updating the broadcastDriveEvent signature to accept recipient IDs, and refactoring all drive event emissions to target specific users instead of a global channel.

Changes

Cohort / File(s) Summary
Realtime Socket Handlers
apps/realtime/src/index.ts
Removes join_global_drives and leave_global_drives socket event handlers along with their associated room management and registry tracking.
Core Broadcasting Logic
apps/web/src/lib/websocket/socket-utils.ts
Updates broadcastDriveEvent signature to accept recipientUserIds: string[] parameter and implements per-user channel routing (user:${userId}:drives) instead of global broadcast; adds early return for empty recipients and Promise.all orchestration.
API Drive Routes
apps/web/src/app/api/drives/route.ts, apps/web/src/app/api/drives/[driveId]/route.ts, apps/web/src/app/api/trash/drives/[driveId]/route.ts
Adds recipient ID lookups via getDriveRecipientUserIds and passes them to broadcastDriveEvent calls for drive creation, updates, deletion, and restoration events.
Activity & Rollback Routes
apps/web/src/app/api/activities/[activityId]/rollback-to-point/route.ts, apps/web/src/app/api/activities/[activityId]/rollback/route.ts, apps/web/src/app/api/drives/[driveId]/restore/route.ts
Introduces getDriveRecipientUserIds imports and passes resolved recipient lists to broadcastDriveEvent when rolling back or restoring drive changes.
MCP Drive Route
apps/web/src/app/api/mcp/drives/route.ts
Updates drive creation broadcast to target creator only by passing [userId] as recipients parameter to broadcastDriveEvent.
AI Drive Tools
apps/web/src/lib/ai/tools/drive-tools.ts, apps/web/src/lib/ai/tools/page-write-tools.ts
Adds getDriveRecipientUserIds imports and updates all drive broadcast operations (creation, rename, trash, restore, context updates) to include recipient lookups.
Socket Hook
apps/web/src/hooks/useGlobalDriveSocket.ts
Replaces global drives channel joins with user-specific channel (user:${userId}:drives); removes global_drives channel emission and leave logic.
Service Layer
packages/lib/src/services/drive-member-service.ts
Adds new public function getDriveRecipientUserIds(driveId: string): Promise<string[]> that collects drive owner and member user IDs for recipient targeting.
Package Exports
packages/lib/package.json
Exports new ./services/drive-member-service module with types and implementation mappings in both exports and typesVersions.
Tests
apps/web/src/lib/websocket/__tests__/socket-utils.test.ts, apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts
Updates test signatures and mocks for broadcastDriveEvent to reflect per-user channel routing; adds getDriveRecipientUserIds mock returning recipient array.

Sequence Diagram(s)

sequenceDiagram
    actor User1
    actor User2
    participant Client as Client/Web App
    participant API as Drive API<br/>(apps/web)
    participant DB as Database
    participant WS as WebSocket<br/>Broadcaster
    participant RT as Realtime Server<br/>(Socket.io)
    
    rect rgba(200, 150, 100, 0.5)
    Note over User1,RT: Old Flow: Global Channel Broadcast
    User1->>Client: Update drive
    Client->>API: PATCH /drives/[id]
    API->>DB: Update drive
    DB-->>API: Success
    API->>WS: broadcastDriveEvent(payload)
    WS->>RT: Emit to global:drives
    RT-->>User1: Event received
    RT-->>User2: Event received (unintended)
    end
    
    rect rgba(100, 150, 200, 0.5)
    Note over User1,RT: New Flow: Per-User Channel Broadcast
    User1->>Client: Update drive
    Client->>API: PATCH /drives/[id]
    API->>DB: Update drive
    DB-->>API: Success
    API->>DB: getDriveRecipientUserIds(driveId)
    DB-->>API: [User1_ID, User2_ID]
    API->>WS: broadcastDriveEvent(payload, [User1_ID, User2_ID])
    WS->>RT: Emit to user:User1_ID:drives
    WS->>RT: Emit to user:User2_ID:drives
    RT-->>User1: Event received
    RT-->>User2: Event received (authorized)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • [Zero Trust] WebSocket authorization #560: Directly addresses the removal of global join_global_drives/leave_global_drives handlers and transitions broadcastDriveEvent to per-user channel targeting, mitigating unintended global drive event exposure.

Possibly related PRs

  • PR #80: Introduces and exports drive-member-service with member/recipient lookup utilities that this PR now leverages in getDriveRecipientUserIds.
  • PR #182: Updates drive-related AI tooling with context-update broadcasts that now require recipient targeting via the modified broadcastDriveEvent signature.
  • PR #248: Modifies useGlobalDriveSocket to replace global socket joins with user-specific channel subscriptions, completing the channel architecture shift.

Poem

🐰 From global drives that broadcast wide and free,
Now whispered secrets shared with those who need to see—
Per-user channels keep the noise at bay,
Recipients receive, intruders turn away! 🔐✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main security fix: preventing cross-tenant drive metadata broadcast by restricting access to authorized users only.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cross-tenant-drives

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts (1)

504-524: 🛠️ Refactor suggestion | 🟠 Major

Tests don't assert that broadcastDriveEvent receives the recipient user IDs.

The core security invariant of this PR is that events are delivered only to authorized recipients. The boundary obligation tests verify broadcastDriveEvent was called but don't assert the second argument (recipientUserIds). Consider strengthening the assertion:

-      expect(broadcastDriveEvent).toHaveBeenCalled();
+      expect(broadcastDriveEvent).toHaveBeenCalledWith(
+        expect.objectContaining({ driveId: mockDriveId, event: 'updated' }),
+        ['user-123', 'user-456']
+      );

The same applies to the DELETE boundary test at Line 732.

🧹 Nitpick comments (3)
apps/web/src/hooks/useGlobalDriveSocket.ts (2)

17-18: Stale comment: still references "global drives channel".

This comment should be updated to reflect the new user-specific channel semantics.

-  // Track if we've joined the global drives channel
+  // Track if we've joined the user-specific drives channel

76-76: Stale comment: references "both global and user-specific".

There is no global channel anymore. Update to avoid confusion.

-    // Listen for drive events (both global and user-specific)
+    // Listen for drive events on user-specific channel
apps/web/src/lib/websocket/socket-utils.ts (1)

200-214: Promise.all will short-circuit on the first rejected fetch, skipping remaining recipients.

If one broadcast request fails (e.g., network blip for one user's channel), the remaining users won't receive the event. Consider Promise.allSettled to ensure best-effort delivery to all recipients, then log any individual failures.

♻️ Suggested improvement
-    await Promise.all(
-      recipientUserIds.map(async (userId) => {
+    const results = await Promise.allSettled(
+      recipientUserIds.map((userId) => {
         const requestBody = JSON.stringify({
           channelId: `user:${userId}:drives`,
           event: `drive:${payload.operation}`,
           payload,
         });
-        await fetch(`${realtimeUrl}/api/broadcast`, {
+        return fetch(`${realtimeUrl}/api/broadcast`, {
           method: 'POST',
           headers: createSignedBroadcastHeaders(requestBody),
           body: requestBody,
         });
       })
     );
+
+    const failures = results.filter((r) => r.status === 'rejected');
+    if (failures.length > 0) {
+      realtimeLogger.warn('Some drive event broadcasts failed', {
+        operation: payload.operation,
+        failedCount: failures.length,
+        totalCount: recipientUserIds.length,
+      });
+    }

- Update stale comments in useGlobalDriveSocket.ts referencing "global drives"
- Strengthen test assertions in route.test.ts to verify recipient userIds
- Replace Promise.all with Promise.allSettled for resilient broadcasting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Addressed CodeRabbit Review Feedback

Thank you @coderabbitai for the thorough review! All suggestions have been addressed in commit 21389f3:

✅ Major: Test Assertions for Recipient UserIds

Updated both PATCH and DELETE boundary tests in route.test.ts to verify the recipientUserIds parameter:

expect(broadcastDriveEvent).toHaveBeenCalledWith(
  expect.objectContaining({ driveId: mockDriveId, event: 'updated' }),
  ['user-123', 'user-456']
);

✅ Nitpick: Stale Comments in useGlobalDriveSocket.ts

  • Line 17: "global drives channel" → "user-specific drives channel"
  • Line 76: "both global and user-specific" → "on user-specific channel"

✅ Nitpick: Promise.allSettled in socket-utils.ts

Changed from Promise.all to Promise.allSettled for resilient broadcasting. If some broadcasts fail, others still complete successfully, with failures logged as warnings.

All tests pass locally (36 route tests + 22 socket-utils tests).

@coderabbitai

coderabbitai Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@2witstudios Thank you for the comprehensive update! 🎉

Excellent work addressing all the feedback:

  1. Test Coverage - The addition of recipientUserIds assertions in your boundary tests ensures the security fix is properly validated. This is crucial for preventing regressions.

  2. Comment Hygiene - Updating the stale references from "global" to "user-specific" keeps the code documentation accurate and maintainable.

  3. Resilient Broadcasting - The switch to Promise.allSettled is a smart choice. It ensures that a failure to broadcast to one recipient doesn't prevent other recipients from receiving their notifications - much better for production reliability.

The security fix looks solid, and your thorough testing gives good confidence in the implementation. Great job on this P1 security issue! 🚀


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant