Files
Jannis Braun 85e1975fa5 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)
2026-07-07 19:45:51 +02:00

105 lines
4.0 KiB
Markdown

# Permission System
Source files:
- `packages/shared/src/permissions.ts` — Bit definitions, constants
- `packages/server/src/utils/permissions.ts` — Server-side resolution
- `packages/web/src/utils/permissions.ts` — Client-side helpers
Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
---
## Permission Bits
| Bit | Name | Description |
|-----|------|-------------|
| 0 | ADMINISTRATOR | Full access, bypasses all checks |
| 1 | VIEW_CHANNEL | See channel, read messages |
| 2 | MANAGE_CHANNELS | Create/edit/delete channels + categories |
| 3 | MANAGE_ROLES | Create/edit/delete roles, assign roles |
| 4 | MANAGE_SPACE | Edit space settings, manage join requests |
| 5 | CREATE_INVITE | Generate invite codes |
| 6 | KICK_MEMBERS | Remove members |
| 7 | BAN_MEMBERS | Ban members |
| 10 | SEND_MESSAGES | Post in text channels |
| 11 | MANAGE_MESSAGES | Delete others' messages |
| 12 | ATTACH_FILES | Upload files |
| 13 | READ_MESSAGE_HISTORY | View message history |
| 14 | ADD_REACTIONS | Add emoji reactions |
| 20 | CONNECT | Join voice channels |
| 21 | SPEAK | Transmit audio |
| 22 | MUTE_MEMBERS | Space-mute others |
| 23 | DEAFEN_MEMBERS | Space-deafen others |
| 24 | MOVE_MEMBERS | Move between voice channels |
| 25 | STREAM | Screen share |
| 26 | DISCONNECT_MEMBERS | Disconnect from voice |
**Default @everyone:** VIEW_CHANNEL, SEND_MESSAGES, CREATE_INVITE, CONNECT, SPEAK, ATTACH_FILES, READ_MESSAGE_HISTORY, ADD_REACTIONS, STREAM
---
## Resolution Algorithm
`computePermissions(userId, spaceId, channelId?)` → bigint
### Step 1: Owner/Admin Check
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
### Step 1b: Membership Gate
- If the user is **not** a member of the space (`getMember` returns nothing) → return `0n`
- A non-member has no permissions in a space they have not joined. Without this,
the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL,
READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member —
allowing them to read channels and mint invite codes for spaces they never
joined. Owner and instance admin are already resolved in Step 1, so they are
unaffected.
### Step 2: Compute Base (space-level)
- Start with @everyone role permissions (role where `id === spaceId`)
- OR together all permissions from user's assigned roles
- If ADMINISTRATOR bit set → return ALL_PERMISSIONS
### Step 3: Apply Overrides (if channelId provided)
Three tiers, each applied category-first then channel-second:
**Tier 1 — @everyone override** (targetType='role', targetId=spaceId):
```
if categoryOverride: base = (base & ~deny) | allow
if channelOverride: base = (base & ~deny) | allow
```
**Tier 2 — Role overrides** (combined across all assigned roles):
```
catAllow = 0, catDeny = 0
for each role: catAllow |= roleOverride.allow; catDeny |= roleOverride.deny
base = (base & ~catDeny) | catAllow
chanAllow = 0, chanDeny = 0
for each role: chanAllow |= roleOverride.allow; chanDeny |= roleOverride.deny
base = (base & ~chanDeny) | chanAllow
```
**Tier 3 — Member override** (targetType='member', targetId=userId):
```
if categoryOverride: base = (base & ~deny) | allow
if channelOverride: base = (base & ~deny) | allow
```
**Key rule:** Channel bits always win — applied after category, overwriting conflicting bits. Deny applied first (clears bits), then allow (sets bits).
---
## Helper Functions
| Function | Purpose |
|----------|---------|
| `hasPermissionBit(perms, bit)` | Check if bit is set; true if ADMINISTRATOR |
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
| `getMember/isMember/isSpaceOwner` | Membership checks |
| `isDmMember/isBanned` | DM/ban checks |
| `getChannelSpaceId(channelId)` | Resolve channel's space |