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:
@@ -425,6 +425,32 @@ describe('InviteModal', () => {
|
||||
expect(screen.getByText('Sam')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an approval-required notice and hides invite affordances for request-only spaces', async () => {
|
||||
const generateInvite = vi.fn().mockResolvedValue('should-not-be-used');
|
||||
useUIStore.setState({ activeModal: 'invite', modalData: {} });
|
||||
useSpaceStore.setState({
|
||||
currentSpaceId: 'space-1',
|
||||
spaces: [makeSpace({ visibility: 'request' })] as any,
|
||||
members: [],
|
||||
generateInvite,
|
||||
} as any);
|
||||
useSocialStore.setState({
|
||||
friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })],
|
||||
} as any);
|
||||
useAuthStore.setState({ user: { id: 'me', username: 'me' } } as any);
|
||||
|
||||
render(<InviteModal />);
|
||||
|
||||
// Explanatory copy replaces the invite UI.
|
||||
expect(screen.getByText(/join request/i)).toBeInTheDocument();
|
||||
// None of the invite affordances render.
|
||||
expect(screen.queryByPlaceholderText('Search friends...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Or share a link')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Alex')).not.toBeInTheDocument();
|
||||
// No invite code is requested for a request-only space (the endpoint 403s).
|
||||
expect(generateInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes federated target shape for remote friends', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSpaceInvite.mockResolvedValue({});
|
||||
|
||||
Reference in New Issue
Block a user