From 82941c08c966476ac2f3e91e5bdb90f06e0da634 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 29 Aug 2026 15:31:42 +0200 Subject: [PATCH] fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) --- .../src/api/csam/NcmecSubmissionService.ts | 68 +++++++++++++++---- .../services/member/GuildMemberRoleService.ts | 6 +- .../member/GuildMemberValidationService.ts | 6 +- .../api/worker/tasks/RefreshSearchIndex.ts | 8 ++- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/fluxer_api/src/api/csam/NcmecSubmissionService.ts b/fluxer_api/src/api/csam/NcmecSubmissionService.ts index 9eb5f127f..ac5d96bbe 100644 --- a/fluxer_api/src/api/csam/NcmecSubmissionService.ts +++ b/fluxer_api/src/api/csam/NcmecSubmissionService.ts @@ -30,6 +30,7 @@ import {Config} from '../Config'; import type {IChannelRepository} from '../channel/IChannelRepository'; import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository'; import { + collectMessageAttachments, makeAttachmentCdnKey, makeAttachmentCdnUrl, purgeMessageAttachments, @@ -43,6 +44,9 @@ import type {IStorageService} from '../infrastructure/IStorageService'; import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService'; import type {UserCacheService} from '../infrastructure/UserCacheService'; import {Logger} from '../Logger'; +import type {Embed} from '../models/Embed'; +import type {EmbedMedia} from '../models/EmbedMedia'; +import type {Message} from '../models/Message'; import type {User} from '../models/User'; import type {IARMessageContext, IARSubmission} from '../report/IReportRepository'; import type {ReportRepository} from '../report/ReportRepository'; @@ -471,18 +475,34 @@ export class NcmecSubmissionService { private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise { const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId); - const liveAttachment = liveMessage?.attachments.find( - (attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename, - ); - if (liveMessage?.authorId && liveAttachment) { - return this.buildAttachmentContext(input, { - contentType: liveAttachment.contentType, - reportedAt: snowflakeToDate(input.messageId), - userId: liveMessage.authorId, - sourceReportId: input.sourceReportId ?? null, - bucket: Config.s3.buckets.cdn, - storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename), - }); + if (liveMessage?.authorId) { + const liveAttachment = collectMessageAttachments(liveMessage).find( + (attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename, + ); + if (liveAttachment) { + return this.buildAttachmentContext(input, { + contentType: liveAttachment.contentType, + reportedAt: snowflakeToDate(input.messageId), + userId: liveMessage.authorId, + sourceReportId: input.sourceReportId ?? null, + bucket: Config.s3.buckets.cdn, + storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename), + }); + } + const embedMedia = findEmbedReferencedAttachmentMedia( + liveMessage, + makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename), + ); + if (embedMedia) { + return this.buildAttachmentContext(input, { + contentType: embedMedia.contentType, + reportedAt: snowflakeToDate(input.messageId), + userId: liveMessage.authorId, + sourceReportId: input.sourceReportId ?? null, + bucket: Config.s3.buckets.cdn, + storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename), + }); + } } if (!input.sourceReportId) { throw new UnknownMessageError(); @@ -939,6 +959,30 @@ function buildAuditMetadata(args: { return metadata; } +function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null { + const scan = (embeds: Array): EmbedMedia | null => { + for (const embed of embeds) { + for (const media of [embed.image, embed.thumbnail, embed.video, embed.audio]) { + if (media?.url === targetCdnUrl) { + return media; + } + } + } + return null; + }; + const direct = scan(message.embeds); + if (direct) { + return direct; + } + for (const snapshot of message.messageSnapshots) { + const found = scan(snapshot.embeds); + if (found) { + return found; + } + } + return null; +} + function findAttachmentInReport( report: IARSubmission, channelId: ChannelID, diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberRoleService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberRoleService.ts index 6576672b6..ab5e64d5a 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberRoleService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberRoleService.ts @@ -42,7 +42,7 @@ export class GuildMemberRoleService { requestCache: RequestCache; }): Promise { const {userId, targetId, guildId, roleId} = params; - const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId}); + const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId}); const targetMember = await this.guildRepository.getMember(guildId, targetId); if (!targetMember) throw new UnknownGuildMemberError(); await this.validationService.validateRoleAssignment({ @@ -51,6 +51,7 @@ export class GuildMemberRoleService { userId, targetId, roleId, + hasPermission, canManageRoles, }); if (targetMember.roleIds.has(roleId)) return; @@ -75,7 +76,7 @@ export class GuildMemberRoleService { requestCache: RequestCache; }): Promise { const {userId, targetId, guildId, roleId} = params; - const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId}); + const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId}); const targetMember = await this.guildRepository.getMember(guildId, targetId); if (!targetMember) throw new UnknownGuildMemberError(); await this.validationService.validateRoleAssignment({ @@ -84,6 +85,7 @@ export class GuildMemberRoleService { userId, targetId, roleId, + hasPermission, canManageRoles, }); if (!targetMember.roleIds.has(roleId)) return; diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts index a4858c2e8..be266e46c 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts @@ -74,9 +74,10 @@ export class GuildMemberValidationService { userId: UserID; targetId: UserID; roleId: RoleID; + hasPermission: (permission: bigint) => Promise; canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise; }): Promise { - const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params; + const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params; ensureNotEveryoneRole(roleId, guildId, 'role_id'); if (guildData && guildData.owner_id === userId.toString()) { const role = await this.guildRepository.getRole(roleId, guildId); @@ -84,6 +85,9 @@ export class GuildMemberValidationService { throw new UnknownGuildRoleError(); } } else { + if (!(await hasPermission(Permissions.MANAGE_ROLES))) { + throw new MissingPermissionsError(); + } if (!(await canManageRoles(targetId, roleId))) { throw new MissingPermissionsError(); } diff --git a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts index 1a64fc9e0..03fd2b890 100644 --- a/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts +++ b/fluxer_api/src/api/worker/tasks/RefreshSearchIndex.ts @@ -21,6 +21,7 @@ import { } from '../../SearchFactory'; import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService'; import type {IMessageSearchService} from '../../search/IMessageSearchService'; +import {deleteChannelMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup'; import {getWorkerDependencies} from '../WorkerContext'; const INDEX_TYPES = [ @@ -190,11 +191,16 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient, const {channelRepository} = getWorkerDependencies(); const guildId = createGuildID(BigInt(payload.guild_id!)); const searchService = requireSearchService(getMessageSearchService()); - await searchService.deleteGuildMessages(guildId); const channels = await channelRepository.listGuildChannels(guildId); if (channels.length === 0) { return 0; } + for (const channel of channels) { + await deleteChannelMessageSearchDocuments(channel.id, { + searchService, + context: {source: 'bulk_reindex', guildId: guildId.toString()}, + }); + } const completionKey = `bulk_reindex:${payload.job_id}:remaining`; await kvClient.del(completionKey); for (const channel of channels) {