fix(api): require manage messages to remove others' reactions (#2799)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-15 18:16:55 +02:00
1 parent 910db6734b
commit 570c8776c4
4 files changed
+144 -27

No files matched your search

@@ -223,7 +223,7 @@ export class MessageInteractionService {
emoji: string;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji, actorId: userId});
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji});
}
async removeAllReactions({
@@ -236,7 +236,7 @@ export class MessageInteractionService {
messageId: MessageID;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await this.reactionService.removeAllReactions({authChannel, messageId, actorId: userId});
await this.reactionService.removeAllReactions({authChannel, messageId});
}
async getMessageReactions({
@@ -12,7 +12,6 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import type {Channel} from '@app/api/models/Channel';
import type {Message} from '@app/api/models/Message';
import type {MessageReaction} from '@app/api/models/MessageReaction';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
@@ -274,7 +273,7 @@ export class MessageReactionService extends MessageInteractionBase {
if (!message) return;
const isRemovingOwnReaction = targetId === actorId;
if (!isRemovingOwnReaction) {
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
await this.assertCanModerateMessageReactions({channel, hasPermission});
}
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
await this.channelRepository.messageInteractions.removeReaction(
@@ -297,12 +296,10 @@ export class MessageReactionService extends MessageInteractionBase {
authChannel,
messageId,
emoji,
actorId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
emoji: string;
actorId: UserID;
}): Promise<void> {
const channel = authChannel.channel;
const {guild, hasPermission} = authChannel;
@@ -314,7 +311,7 @@ export class MessageReactionService extends MessageInteractionBase {
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) return;
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
await this.assertCanModerateMessageReactions({channel, hasPermission});
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
await this.channelRepository.messageInteractions.removeAllReactionsForEmoji(
channel.id,
@@ -332,11 +329,9 @@ export class MessageReactionService extends MessageInteractionBase {
async removeAllReactions({
authChannel,
messageId,
actorId,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
actorId: UserID;
}): Promise<void> {
const channel = authChannel.channel;
const {guild, hasPermission} = authChannel;
@@ -347,7 +342,7 @@ export class MessageReactionService extends MessageInteractionBase {
}
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) return;
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
await this.assertCanModerateMessageReactions({channel, hasPermission});
await this.channelRepository.messageInteractions.removeAllReactions(channel.id, messageId);
await this.dispatchMessageReactionRemoveAll({channel, messageId});
}
@@ -365,18 +360,11 @@ export class MessageReactionService extends MessageInteractionBase {
private async assertCanModerateMessageReactions({
channel,
message,
actorId,
hasPermission,
}: {
channel: Channel;
message: Message;
actorId: UserID;
hasPermission: (permission: bigint) => Promise<boolean>;
}): Promise<void> {
if (message.authorId === actorId) {
return;
}
if (!channel.guildId) {
throw new MissingPermissionsError();
}
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
createDmChannel,
createFriendship,
createPermissionOverwrite,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const EMOJI = encodeURIComponent('👍');
interface ReactionUsersPage {
items: Array<{id: string}>;
}
describe('Reaction removal permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function react(account: TestAccount, channelId: string, messageId: string): Promise<void> {
await createBuilder(harness, account.token)
.put(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/@me`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
async function listReactorIds(account: TestAccount, channelId: string, messageId: string): Promise<Array<string>> {
const page = await createBuilder<ReactionUsersPage>(harness, account.token)
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/users`)
.execute();
return page.items.map((user) => user.id);
}
async function expectRemovalsRejected(
account: TestAccount,
channelId: string,
messageId: string,
targetId: string,
): Promise<void> {
const base = `/channels/${channelId}/messages/${messageId}/reactions`;
for (const route of [`${base}/${EMOJI}/${targetId}`, `${base}/${EMOJI}`, base]) {
await createBuilder(harness, account.token)
.delete(route)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
}
}
it('requires MANAGE_MESSAGES for the author to remove reactions from their own guild message', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, reactor] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
await react(reactor, systemChannel.id, message.id);
await expectRemovalsRejected(author, systemChannel.id, message.id, reactor.userId);
expect(await listReactorIds(author, systemChannel.id, message.id)).toEqual([reactor.userId]);
});
it('refuses the author removing reactions from their own direct message', async () => {
const author = await createTestAccount(harness);
const reactor = await createTestAccount(harness);
await createFriendship(harness, author, reactor);
const dm = await createDmChannel(harness, author.token, reactor.userId);
const message = await sendChannelMessage(harness, author.token, dm.id, 'react to me');
await react(reactor, dm.id, message.id);
await expectRemovalsRejected(author, dm.id, message.id, reactor.userId);
expect(await listReactorIds(author, dm.id, message.id)).toEqual([reactor.userId]);
});
it('lets a member with MANAGE_MESSAGES remove reactions from another member message', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 3);
const [author, reactor, moderator] = members as [TestAccount, TestAccount, TestAccount];
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
type: 1,
allow: Permissions.MANAGE_MESSAGES.toString(),
deny: '0',
});
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
const base = `/channels/${systemChannel.id}/messages/${message.id}/reactions`;
await react(reactor, systemChannel.id, message.id);
await createBuilder(harness, moderator.token)
.delete(`${base}/${EMOJI}/${reactor.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
await react(reactor, systemChannel.id, message.id);
await createBuilder(harness, moderator.token).delete(`${base}/${EMOJI}`).expect(HTTP_STATUS.NO_CONTENT).execute();
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
await react(reactor, systemChannel.id, message.id);
await createBuilder(harness, moderator.token).delete(base).expect(HTTP_STATUS.NO_CONTENT).execute();
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
});
it('lets a member without MANAGE_MESSAGES remove their own reaction by user ID', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
const [author, reactor] = members as [TestAccount, TestAccount];
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
await react(reactor, systemChannel.id, message.id);
await createBuilder(harness, reactor.token)
.delete(`/channels/${systemChannel.id}/messages/${message.id}/reactions/${EMOJI}/${reactor.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await listReactorIds(author, systemChannel.id, message.id)).toEqual([]);
});
});