fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without verifying the caller had joined the space. Because CREATE_INVITE is in DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code for a request-only space — whose id is listed by /api/spaces/explore — and then self-join via /api/spaces/:id/join, bypassing the join-request approval flow. The same gap let non-members read message history and search default channels. Root cause: - computePermissions now returns 0n for non-members (space owner and instance admin still short-circuit first, so they are unaffected). Defense in depth (request-only spaces are approval-gated, never invite-joinable): - both invite-code join endpoints reject visibility='request' (private stays invite-joinable — its only entry path; public too). - POST /api/spaces/:id/invite refuses to hand out a code for request spaces. - POST /api/dm/space-invite refuses to card a local request space, checked by space id against the local table so a spoofed spaceInstanceOrigin can't slip past it. - InviteModal hides the invite affordances for request spaces. Also removes the unused computeCategoryPermissions(), which duplicated the resolution algorithm without the membership gate. Adds unit + route + component tests covering non-member/member/owner/admin resolution and the request/private/public visibility matrix. Reported-by: BadAtCaptchas (#2)
This commit is contained in:
@@ -54,6 +54,18 @@ function seedSpace(spaceId: string): void {
|
||||
}).run();
|
||||
}
|
||||
|
||||
// Enroll a user as a space member. computePermissions grants @everyone
|
||||
// permissions only to actual members, and every real join path inserts this row
|
||||
// before voice state is built/pushed — so visibility tests must seed it too.
|
||||
function seedMember(spaceId: string, userId: string): void {
|
||||
seedUser(userId);
|
||||
testDb.insert(schema.spaceMembers).values({
|
||||
spaceId,
|
||||
userId,
|
||||
joinedAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
function seedChannel(id: string, spaceId: string, type: 'text' | 'voice'): void {
|
||||
testDb.insert(schema.channels).values({
|
||||
id,
|
||||
@@ -178,6 +190,7 @@ describe('connectionManager.buildSpaceVoiceState', () => {
|
||||
const privateCh = 'vc-private-1';
|
||||
seedSpace(spaceId);
|
||||
seedEveryoneRole(spaceId);
|
||||
seedMember(spaceId, 'u-viewer');
|
||||
seedChannel(publicCh, spaceId, 'voice');
|
||||
seedChannel(privateCh, spaceId, 'voice');
|
||||
seedDenyViewOverride(privateCh, spaceId);
|
||||
@@ -204,6 +217,7 @@ describe('connectionManager.addUserSpace voice-state push', () => {
|
||||
const voiceCh = 'vc-push-1';
|
||||
seedSpace(spaceId);
|
||||
seedEveryoneRole(spaceId);
|
||||
seedMember(spaceId, 'u-joiner');
|
||||
seedChannel(voiceCh, spaceId, 'voice');
|
||||
|
||||
cm.createRoom(voiceCh, 'space', { type: 'space', spaceId });
|
||||
|
||||
Reference in New Issue
Block a user