fix(api): correct guild permission and mfa checks (#2499)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:28:01 +02:00
1 parent f392636857
commit 908e1b8bd4
12 files changed
+263 -46

No files matched your search

@@ -174,7 +174,7 @@ export abstract class BaseChannelAuthService {
const guildId = channel.guildId!; const guildId = channel.guildId!;
const [authContextResult, guildMemberResult] = await Promise.all([ const [authContextResult, guildMemberResult] = await Promise.all([
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}), this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
this.gatewayService.getGuildMember({guildId, userId}), this.fetchGuildMemberOrThrow({guildId, userId}),
]); ]);
if (!authContextResult) { if (!authContextResult) {
this.throwGuildAccessError(); this.throwGuildAccessError();
@@ -285,6 +285,18 @@ export abstract class BaseChannelAuthService {
} }
} }
private async fetchGuildMemberOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<{
success: boolean;
memberData?: GuildMemberResponse;
}> {
try {
return await this.gatewayService.getGuildMember(params);
} catch (error) {
await this.handleGuildAccessError(error, params.guildId);
throw error;
}
}
private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> { private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> {
if (error instanceof UnknownGuildError) { if (error instanceof UnknownGuildError) {
if (await this.guildExists(guildId)) { if (await this.guildExists(guildId)) {
@@ -2,6 +2,7 @@
import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants'; import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createTestAccount} from '../../auth/tests/AuthTestUtils';
@@ -9,7 +10,7 @@ import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils'; import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
import {getGatewayService} from '../../middleware/ServiceRegistry'; import {getGatewayService} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants'; import {HTTP_STATUS, TEST_TIMEOUTS, wait} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder'; import {createBuilder} from '../../test/TestRequestBuilder';
import {ChannelDataRepository} from '../repositories/ChannelDataRepository'; import {ChannelDataRepository} from '../repositories/ChannelDataRepository';
import { import {
@@ -169,6 +170,34 @@ describe('Message send permissions', () => {
} }
}); });
it('returns ACCESS_DENIED not UNKNOWN_GUILD when the Gateway has dropped a guild whose record survives', async () => {
const {owner, systemChannel} = await setupTestGuildWithMembers(harness, 0);
await ensureSessionStarted(harness, owner.token);
const gatewayService = getGatewayService();
for (const memberSettlesFirst of [true, false]) {
const getGuildAuthContext = vi.spyOn(gatewayService, 'getGuildAuthContext').mockImplementation(async () => {
if (memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE);
throw new UnknownGuildError();
});
const getGuildMember = vi.spyOn(gatewayService, 'getGuildMember').mockImplementation(async () => {
if (!memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE);
throw new UnknownGuildError();
});
try {
await createBuilder(harness, owner.token)
.post(`/channels/${systemChannel.id}/messages`)
.body({content: 'the gateway forgot this guild'})
.expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED')
.execute();
} finally {
getGuildAuthContext.mockRestore();
getGuildMember.mockRestore();
}
}
});
it('allows bot message content up to 4000 characters', async () => { it('allows bot message content up to 4000 characters', async () => {
const {owner, guild, systemChannel} = await setupTestGuildWithMembers(harness, 0); const {owner, guild, systemChannel} = await setupTestGuildWithMembers(harness, 0);
const botAccount = await createTestBotAccount(harness); const botAccount = await createTestBotAccount(harness);
@@ -3,7 +3,6 @@
import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants'; import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError'; import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError'; import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError'; import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
@@ -160,14 +159,8 @@ export function GuildDiscoveryController(app: HonoApp) {
const {guild_id} = ctx.req.valid('param'); const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id); const guildId = createGuildID(guild_id);
const data = ctx.req.valid('json'); const data = ctx.req.valid('json');
const hasPermission = await ctx.get('gatewayService').checkPermission({ const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
guildId, await checkPermission(Permissions.MANAGE_GUILD);
userId: user.id,
permission: Permissions.MANAGE_GUILD,
});
if (!hasPermission) {
throw new MissingPermissionsError();
}
const row = await ctx.get('discoveryService').apply({ const row = await ctx.get('discoveryService').apply({
guildId, guildId,
userId: user.id, userId: user.id,
@@ -201,14 +194,8 @@ export function GuildDiscoveryController(app: HonoApp) {
const {guild_id} = ctx.req.valid('param'); const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id); const guildId = createGuildID(guild_id);
const data = ctx.req.valid('json'); const data = ctx.req.valid('json');
const hasPermission = await ctx.get('gatewayService').checkPermission({ const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
guildId, await checkPermission(Permissions.MANAGE_GUILD);
userId: user.id,
permission: Permissions.MANAGE_GUILD,
});
if (!hasPermission) {
throw new MissingPermissionsError();
}
const row = await ctx.get('discoveryService').editApplication({ const row = await ctx.get('discoveryService').editApplication({
guildId, guildId,
userId: user.id, userId: user.id,
@@ -237,14 +224,8 @@ export function GuildDiscoveryController(app: HonoApp) {
const user = ctx.get('user'); const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param'); const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id); const guildId = createGuildID(guild_id);
const hasPermission = await ctx.get('gatewayService').checkPermission({ const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
guildId, await checkPermission(Permissions.MANAGE_GUILD);
userId: user.id,
permission: Permissions.MANAGE_GUILD,
});
if (!hasPermission) {
throw new MissingPermissionsError();
}
await ctx.get('discoveryService').withdraw({guildId, userId: user.id}); await ctx.get('discoveryService').withdraw({guildId, userId: user.id});
return ctx.body(null, 204); return ctx.body(null, 204);
}, },
@@ -267,14 +248,8 @@ export function GuildDiscoveryController(app: HonoApp) {
const user = ctx.get('user'); const user = ctx.get('user');
const {guild_id} = ctx.req.valid('param'); const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id); const guildId = createGuildID(guild_id);
const hasPermission = await ctx.get('gatewayService').checkPermission({ const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
guildId, await checkPermission(Permissions.MANAGE_GUILD);
userId: user.id,
permission: Permissions.MANAGE_GUILD,
});
if (!hasPermission) {
throw new MissingPermissionsError();
}
const discoveryService = ctx.get('discoveryService'); const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.getStatus(guildId); const row = await discoveryService.getStatus(guildId);
const eligibility = await discoveryService.getEligibility(guildId); const eligibility = await discoveryService.getEligibility(guildId);
@@ -20,6 +20,7 @@ import {guildMembersNeedReindexing} from '../../search/GuildMemberIndexingUtils'
import type {HonoApp} from '../../types/HonoEnv'; import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator'; import {Validator} from '../../Validator';
import {GuildRepository} from '../repositories/GuildRepository'; import {GuildRepository} from '../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../services/GuildMfaEnforcement';
const MEMBERS_PAGE_PERMISSIONS = const MEMBERS_PAGE_PERMISSIONS =
Permissions.MANAGE_GUILD | Permissions.MANAGE_GUILD |
@@ -81,11 +82,17 @@ export function GuildMemberSearchController(app: HonoApp) {
const guildId = createGuildID(ctx.req.valid('param').guild_id); const guildId = createGuildID(ctx.req.valid('param').guild_id);
const guildIdString = guildId.toString(); const guildIdString = guildId.toString();
const body = ctx.req.valid('json'); const body = ctx.req.valid('json');
const {getMyPermissions} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId}); const {getMyPermissions, guildData} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId});
const permissions = await getMyPermissions(); const permissions = await getMyPermissions();
if ((permissions & MEMBERS_PAGE_PERMISSIONS) === 0n) { if ((permissions & MEMBERS_PAGE_PERMISSIONS) === 0n) {
throw new MissingPermissionsError(); throw new MissingPermissionsError();
} }
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: ctx.get('userRepository'),
guildData,
userId,
});
enforceGuildMfa(permissions & MEMBERS_PAGE_PERMISSIONS);
const canViewInvites = (permissions & Permissions.MANAGE_GUILD) !== 0n; const canViewInvites = (permissions & Permissions.MANAGE_GUILD) !== 0n;
const guild = await guildRepository.findUnique(guildId); const guild = await guildRepository.findUnique(guildId);
if (!guild) { if (!guild) {
@@ -914,11 +914,16 @@ export class GuildMemberOperationsService {
if (userId !== targetId) { if (userId !== targetId) {
await checkTargetMember(targetId); await checkTargetMember(targetId);
} }
if (data.mute !== undefined || data.deaf !== undefined) { if (data.mute !== undefined) {
if (!(await hasPermission(Permissions.MUTE_MEMBERS))) { if (!(await hasPermission(Permissions.MUTE_MEMBERS))) {
throw new MissingPermissionsError(); throw new MissingPermissionsError();
} }
} }
if (data.deaf !== undefined) {
if (!(await hasPermission(Permissions.DEAFEN_MEMBERS))) {
throw new MissingPermissionsError();
}
}
if (data.channel_id !== undefined) { if (data.channel_id !== undefined) {
if (!(await hasPermission(Permissions.MOVE_MEMBERS))) { if (!(await hasPermission(Permissions.MOVE_MEMBERS))) {
throw new MissingPermissionsError(); throw new MissingPermissionsError();
@@ -1,17 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants'; import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import type { import type {
DiscoveryApplicationResponse, DiscoveryApplicationResponse,
DiscoveryStatusResponse, DiscoveryStatusResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas'; } from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest'; import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils'; import {createTestAccount, setUserACLs, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants'; import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder'; import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createGuild, getGuild} from './GuildTestUtils'; import {addMemberRole, createGuild, createRole, getGuild, setupTestGuildWithMembers} from './GuildTestUtils';
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> { async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
await createBuilder(harness, '') await createBuilder(harness, '')
@@ -67,6 +71,33 @@ async function adminReject(
.execute(); .execute();
} }
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<void> {
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: account.password})
.expect(HTTP_STATUS.OK)
.execute();
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
const loginResp = await createBuilderWithoutAuth<{
mfa: true;
ticket: string;
}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.OK)
.execute();
const mfaResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket})
.expect(HTTP_STATUS.OK)
.execute();
return {...account, token: mfaResp.token};
}
async function createAdminAccount(harness: ApiTestHarness) { async function createAdminAccount(harness: ApiTestHarness) {
const admin = await createTestAccount(harness); const admin = await createTestAccount(harness);
return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']); return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']);
@@ -251,6 +282,28 @@ describe('Discovery Application Lifecycle', () => {
const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc'); const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc');
expect(application.description).toBe('Short desc'); expect(application.description).toBe('Short desc');
}); });
test('rejects apply with TWO_FACTOR_REQUIRED for a non-owner without MFA in an elevated guild', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
const manageGuildRole = await createRole(harness, owner.token, guild.id, {
name: 'Manage Guild',
permissions: Permissions.MANAGE_GUILD.toString(),
});
await addMemberRole(harness, owner.token, guild.id, member.userId, manageGuildRole.id);
await enableTotp(harness, owner);
const elevatedOwner = await loginWithTotp(harness, owner);
await createBuilder(harness, elevatedOwner.token)
.patch(`/guilds/${guild.id}`)
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
.expect(HTTP_STATUS.OK)
.execute();
await setGuildMemberCount(harness, guild.id, 1);
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/discovery`)
.body({description: 'Moderator without an authenticator', category_type: DiscoveryCategories.GAMING})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.TWO_FACTOR_REQUIRED)
.execute();
});
test('should allow applying with maximum description length', async () => { test('should allow applying with maximum description length', async () => {
const owner = await createTestAccount(harness); const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Max Desc Guild'); const guild = await createGuild(harness, owner.token, 'Max Desc Guild');
@@ -530,6 +530,66 @@ describe('Guild Member Management', () => {
.execute(); .execute();
}); });
}); });
describe('Voice Mute and Deafen Permission Checks', () => {
test('should not allow member with MUTE_MEMBERS only to deafen a member', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [moderator, target] = members;
const modRole = await createRole(harness, owner.token, guild.id, {
name: 'Muter',
permissions: Permissions.MUTE_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
await createBuilder(harness, moderator.token)
.patch(`/guilds/${guild.id}/members/${target.userId}`)
.body({deaf: true})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
test('should not allow member with DEAFEN_MEMBERS only to mute a member', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [moderator, target] = members;
const modRole = await createRole(harness, owner.token, guild.id, {
name: 'Deafener',
permissions: Permissions.DEAFEN_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
await createBuilder(harness, moderator.token)
.patch(`/guilds/${guild.id}/members/${target.userId}`)
.body({mute: true})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
test('should allow member with DEAFEN_MEMBERS only to deafen a member', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [moderator, target] = members;
const modRole = await createRole(harness, owner.token, guild.id, {
name: 'Deafener',
permissions: Permissions.DEAFEN_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
const updatedMember = await createBuilder<GuildMemberResponse>(harness, moderator.token)
.patch(`/guilds/${guild.id}/members/${target.userId}`)
.body({deaf: true})
.expect(HTTP_STATUS.OK)
.execute();
expect(updatedMember.deaf).toBe(true);
});
test('should allow member with MUTE_MEMBERS only to mute a member', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [moderator, target] = members;
const modRole = await createRole(harness, owner.token, guild.id, {
name: 'Muter',
permissions: Permissions.MUTE_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
const updatedMember = await createBuilder<GuildMemberResponse>(harness, moderator.token)
.patch(`/guilds/${guild.id}/members/${target.userId}`)
.body({mute: true})
.expect(HTTP_STATUS.OK)
.execute();
expect(updatedMember.mute).toBe(true);
});
});
describe('Member Avatar Upload Validation', () => { describe('Member Avatar Upload Validation', () => {
test('should reject member avatar upload without premium', async () => { test('should reject member avatar upload without premium', async () => {
const {members, guild} = await setupTestGuildWithMembers(harness, 1); const {members, guild} = await setupTestGuildWithMembers(harness, 1);
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants'; import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
import type {GuildMemberSearchResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas'; import type {GuildMemberSearchResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest'; import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createTestAccount, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils';
import {createGuildID, createUserID} from '../../BrandedTypes'; import {createGuildID, createUserID} from '../../BrandedTypes';
import {GuildRepository} from '../../guild/repositories/GuildRepository'; import {GuildRepository} from '../../guild/repositories/GuildRepository';
import { import {
@@ -17,7 +18,37 @@ import {
} from '../../guild/tests/GuildTestUtils'; } from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS, wait} from '../../test/TestConstants'; import {HTTP_STATUS, wait} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder'; import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
async function elevateGuildMfaLevel(harness: ApiTestHarness, owner: TestAccount, guildId: string): Promise<void> {
await createBuilder(harness, owner.token)
.post('/users/@me/mfa/totp/enable')
.body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: owner.password})
.expect(HTTP_STATUS.OK)
.execute();
const loginResp = await createBuilderWithoutAuth<{
mfa: true;
ticket: string;
}>(harness)
.post('/auth/login')
.body({email: owner.email, password: owner.password})
.expect(HTTP_STATUS.OK)
.execute();
const mfaResp = await createBuilderWithoutAuth<{
token: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, mfaResp.token)
.patch(`/guilds/${guildId}`)
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
.expect(HTTP_STATUS.OK)
.execute();
}
async function indexGuildMembers(harness: ApiTestHarness, guildId: string): Promise<void> { async function indexGuildMembers(harness: ApiTestHarness, guildId: string): Promise<void> {
await createBuilder<{ await createBuilder<{
@@ -325,6 +356,39 @@ describe('Guild Member Search Endpoint', () => {
.execute(); .execute();
expect(result.members.length).toBeGreaterThan(0); expect(result.members.length).toBeGreaterThan(0);
}); });
test('rejects an elevated-permission holder without MFA in an elevated guild (400)', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const moderator = members[0]!;
const banRole = await createRole(harness, owner.token, guild.id, {
name: 'Ban Members',
permissions: Permissions.BAN_MEMBERS.toString(),
});
await updateMember(harness, owner.token, guild.id, moderator.userId, {roles: [banRole.id]});
await indexGuildMembers(harness, guild.id);
await elevateGuildMfaLevel(harness, owner, guild.id);
await createBuilder(harness, moderator.token)
.post(`/guilds/${guild.id}/members-search`)
.body({})
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
.execute();
});
test('allows a MANAGE_NICKNAMES holder without MFA in an elevated guild', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const nicknameManager = members[0]!;
const nicknameRole = await createRole(harness, owner.token, guild.id, {
name: 'Manage Nicknames',
permissions: Permissions.MANAGE_NICKNAMES.toString(),
});
await updateMember(harness, owner.token, guild.id, nicknameManager.userId, {roles: [nicknameRole.id]});
await indexGuildMembers(harness, guild.id);
await elevateGuildMfaLevel(harness, owner, guild.id);
const result = await createBuilder<GuildMemberSearchResponse>(harness, nicknameManager.token)
.post(`/guilds/${guild.id}/members-search`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.members.length).toBeGreaterThan(0);
});
test('returns 404 for non-guild-member', async () => { test('returns 404 for non-guild-member', async () => {
const {guild} = await setupTestGuildWithMembers(harness, 1); const {guild} = await setupTestGuildWithMembers(harness, 1);
const outsider = await createTestAccount(harness); const outsider = await createTestAccount(harness);
@@ -246,6 +246,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
const isBlocked = relationshipType === RelationshipTypes.BLOCKED; const isBlocked = relationshipType === RelationshipTypes.BLOCKED;
const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id); const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id);
const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false; const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false;
const canDeafenMembers = guildId ? Permission.can(Permissions.DEAFEN_MEMBERS, {guildId}) : false;
const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false; const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false;
const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false; const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false;
const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false; const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false;
@@ -1181,9 +1182,10 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
} }
if (guildId && member) { if (guildId && member) {
const nextModerationActions: Array<MenuItemType | MenuCheckboxType> = []; const nextModerationActions: Array<MenuItemType | MenuCheckboxType> = [];
if (canMuteMembers && (isCurrentUser || canManageTarget(user.id))) { if ((canMuteMembers || canDeafenMembers) && (isCurrentUser || canManageTarget(user.id))) {
const isGuildMuted = memberMute; const isGuildMuted = memberMute;
const isGuildDeafened = memberDeaf; const isGuildDeafened = memberDeaf;
if (canMuteMembers) {
nextModerationActions.push({ nextModerationActions.push({
icon: ( icon: (
<GuildMuteIcon <GuildMuteIcon
@@ -1204,6 +1206,8 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
} }
}, },
}); });
}
if (canDeafenMembers) {
nextModerationActions.push({ nextModerationActions.push({
icon: ( icon: (
<GuildDeafenIcon <GuildDeafenIcon
@@ -1225,6 +1229,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
}, },
}); });
} }
}
if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) { if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) {
nextModerationActions.push({ nextModerationActions.push({
icon: ( icon: (
@@ -1396,6 +1401,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
canMention, canMention,
focusedChannelId, focusedChannelId,
canMuteMembers, canMuteMembers,
canDeafenMembers,
canMoveMembers, canMoveMembers,
canManageTarget, canManageTarget,
canKickTarget, canKickTarget,
@@ -17,6 +17,7 @@ const DEFAULT_PERMISSIONS: VoiceChannelPermissions = {
canUseVideo: true, canUseVideo: true,
canConnect: true, canConnect: true,
canPrioritySpeaker: false, canPrioritySpeaker: false,
canUseVad: true,
}; };
function permissions(overrides: Partial<VoiceChannelPermissions> = {}): VoiceChannelPermissions { function permissions(overrides: Partial<VoiceChannelPermissions> = {}): VoiceChannelPermissions {
@@ -62,6 +62,7 @@ export const DEFAULT_VOICE_PERMISSION_STATE: VoiceChannelPermissions = {
canUseVideo: true, canUseVideo: true,
canConnect: true, canConnect: true,
canPrioritySpeaker: false, canPrioritySpeaker: false,
canUseVad: true,
}; };
const EMPTY_COMMANDS: ReadonlyArray<VoicePermissionCommand> = []; const EMPTY_COMMANDS: ReadonlyArray<VoicePermissionCommand> = [];
@@ -86,7 +87,8 @@ function permissionsEqual(left: VoiceChannelPermissions, right: VoiceChannelPerm
left.canStream === right.canStream && left.canStream === right.canStream &&
left.canUseVideo === right.canUseVideo && left.canUseVideo === right.canUseVideo &&
left.canConnect === right.canConnect && left.canConnect === right.canConnect &&
left.canPrioritySpeaker === right.canPrioritySpeaker left.canPrioritySpeaker === right.canPrioritySpeaker &&
left.canUseVad === right.canUseVad
); );
} }
@@ -11,6 +11,7 @@ export interface VoiceChannelPermissions {
canUseVideo: boolean; canUseVideo: boolean;
canConnect: boolean; canConnect: boolean;
canPrioritySpeaker: boolean; canPrioritySpeaker: boolean;
canUseVad: boolean;
} }
export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = { export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = {
@@ -19,6 +20,7 @@ export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = {
canUseVideo: true, canUseVideo: true,
canConnect: true, canConnect: true,
canPrioritySpeaker: false, canPrioritySpeaker: false,
canUseVad: true,
}; };
export type VoicePermissionMuteReason = 'server_suppress' | 'missing_speak_permission' | null; export type VoicePermissionMuteReason = 'server_suppress' | 'missing_speak_permission' | null;
@@ -38,6 +40,7 @@ export function getVoiceChannelPermissions(channelId: string | null | undefined)
canUseVideo: (permissions & Permissions.STREAM) === Permissions.STREAM, canUseVideo: (permissions & Permissions.STREAM) === Permissions.STREAM,
canConnect: (permissions & Permissions.CONNECT) === Permissions.CONNECT, canConnect: (permissions & Permissions.CONNECT) === Permissions.CONNECT,
canPrioritySpeaker: (permissions & Permissions.PRIORITY_SPEAKER) === Permissions.PRIORITY_SPEAKER, canPrioritySpeaker: (permissions & Permissions.PRIORITY_SPEAKER) === Permissions.PRIORITY_SPEAKER,
canUseVad: (permissions & Permissions.USE_VAD) === Permissions.USE_VAD,
}; };
} }