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({});
|
||||
|
||||
@@ -149,6 +149,10 @@ export function InviteModal() {
|
||||
const isOpen = activeModal === 'invite';
|
||||
const currentSpace = spaces.find((s) => s.id === currentSpaceId);
|
||||
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
|
||||
// Request-only spaces are approval-gated: they have no usable invite link and
|
||||
// the /invite endpoint 403s. Show an explanatory notice instead of the invite
|
||||
// affordances, and skip the invite-code fetch entirely.
|
||||
const isRequestOnly = currentSpace?.visibility === 'request';
|
||||
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
@@ -167,7 +171,7 @@ export function InviteModal() {
|
||||
|
||||
// Fetch / generate the per-space invite code on open.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !currentSpaceId) return;
|
||||
if (!isOpen || !currentSpaceId || isRequestOnly) return;
|
||||
setCodeLoading(true);
|
||||
setCodeError('');
|
||||
generateInvite(currentSpaceId).then(
|
||||
@@ -180,7 +184,7 @@ export function InviteModal() {
|
||||
setCodeLoading(false);
|
||||
},
|
||||
);
|
||||
}, [isOpen, currentSpaceId, generateInvite]);
|
||||
}, [isOpen, currentSpaceId, generateInvite, isRequestOnly]);
|
||||
|
||||
// Reset modal state on open.
|
||||
useEffect(() => {
|
||||
@@ -333,6 +337,20 @@ export function InviteModal() {
|
||||
title="Invite Friends"
|
||||
mobileStyle="sheet"
|
||||
>
|
||||
{isRequestOnly ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[13px] text-txt-tertiary">
|
||||
This space uses join requests — people join by requesting approval
|
||||
from a manager, so it has no invite link to share.
|
||||
</p>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="w-full py-2 rounded-md text-[13px] font-semibold glass-pill text-txt-primary"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[13px] text-txt-tertiary">
|
||||
Send to friends, or share a link.
|
||||
@@ -460,6 +478,7 @@ export function InviteModal() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user