fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-29 15:31:42 +02:00
1 parent 8d21c97d08
commit 82941c08c9
4 files changed
+72 -16

No files matched your search

@@ -30,6 +30,7 @@ import {Config} from '../Config';
import type {IChannelRepository} from '../channel/IChannelRepository'; import type {IChannelRepository} from '../channel/IChannelRepository';
import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository'; import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository';
import { import {
collectMessageAttachments,
makeAttachmentCdnKey, makeAttachmentCdnKey,
makeAttachmentCdnUrl, makeAttachmentCdnUrl,
purgeMessageAttachments, purgeMessageAttachments,
@@ -43,6 +44,9 @@ import type {IStorageService} from '../infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService'; import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '../infrastructure/UserCacheService'; import type {UserCacheService} from '../infrastructure/UserCacheService';
import {Logger} from '../Logger'; 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 {User} from '../models/User';
import type {IARMessageContext, IARSubmission} from '../report/IReportRepository'; import type {IARMessageContext, IARSubmission} from '../report/IReportRepository';
import type {ReportRepository} from '../report/ReportRepository'; import type {ReportRepository} from '../report/ReportRepository';
@@ -471,18 +475,34 @@ export class NcmecSubmissionService {
private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> { private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> {
const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId); const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId);
const liveAttachment = liveMessage?.attachments.find( if (liveMessage?.authorId) {
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename, const liveAttachment = collectMessageAttachments(liveMessage).find(
); (attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
if (liveMessage?.authorId && liveAttachment) { );
return this.buildAttachmentContext(input, { if (liveAttachment) {
contentType: liveAttachment.contentType, return this.buildAttachmentContext(input, {
reportedAt: snowflakeToDate(input.messageId), contentType: liveAttachment.contentType,
userId: liveMessage.authorId, reportedAt: snowflakeToDate(input.messageId),
sourceReportId: input.sourceReportId ?? null, userId: liveMessage.authorId,
bucket: Config.s3.buckets.cdn, sourceReportId: input.sourceReportId ?? null,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename), 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) { if (!input.sourceReportId) {
throw new UnknownMessageError(); throw new UnknownMessageError();
@@ -939,6 +959,30 @@ function buildAuditMetadata(args: {
return metadata; return metadata;
} }
function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null {
const scan = (embeds: Array<Embed>): 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( function findAttachmentInReport(
report: IARSubmission, report: IARSubmission,
channelId: ChannelID, channelId: ChannelID,
@@ -42,7 +42,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache; requestCache: RequestCache;
}): Promise<void> { }): Promise<void> {
const {userId, targetId, guildId, roleId} = params; 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); const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError(); if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({ await this.validationService.validateRoleAssignment({
@@ -51,6 +51,7 @@ export class GuildMemberRoleService {
userId, userId,
targetId, targetId,
roleId, roleId,
hasPermission,
canManageRoles, canManageRoles,
}); });
if (targetMember.roleIds.has(roleId)) return; if (targetMember.roleIds.has(roleId)) return;
@@ -75,7 +76,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache; requestCache: RequestCache;
}): Promise<void> { }): Promise<void> {
const {userId, targetId, guildId, roleId} = params; 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); const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError(); if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({ await this.validationService.validateRoleAssignment({
@@ -84,6 +85,7 @@ export class GuildMemberRoleService {
userId, userId,
targetId, targetId,
roleId, roleId,
hasPermission,
canManageRoles, canManageRoles,
}); });
if (!targetMember.roleIds.has(roleId)) return; if (!targetMember.roleIds.has(roleId)) return;
@@ -74,9 +74,10 @@ export class GuildMemberValidationService {
userId: UserID; userId: UserID;
targetId: UserID; targetId: UserID;
roleId: RoleID; roleId: RoleID;
hasPermission: (permission: bigint) => Promise<boolean>;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>; canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}): Promise<void> { }): Promise<void> {
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params; const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params;
ensureNotEveryoneRole(roleId, guildId, 'role_id'); ensureNotEveryoneRole(roleId, guildId, 'role_id');
if (guildData && guildData.owner_id === userId.toString()) { if (guildData && guildData.owner_id === userId.toString()) {
const role = await this.guildRepository.getRole(roleId, guildId); const role = await this.guildRepository.getRole(roleId, guildId);
@@ -84,6 +85,9 @@ export class GuildMemberValidationService {
throw new UnknownGuildRoleError(); throw new UnknownGuildRoleError();
} }
} else { } else {
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
throw new MissingPermissionsError();
}
if (!(await canManageRoles(targetId, roleId))) { if (!(await canManageRoles(targetId, roleId))) {
throw new MissingPermissionsError(); throw new MissingPermissionsError();
} }
@@ -21,6 +21,7 @@ import {
} from '../../SearchFactory'; } from '../../SearchFactory';
import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService'; import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService';
import type {IMessageSearchService} from '../../search/IMessageSearchService'; import type {IMessageSearchService} from '../../search/IMessageSearchService';
import {deleteChannelMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
import {getWorkerDependencies} from '../WorkerContext'; import {getWorkerDependencies} from '../WorkerContext';
const INDEX_TYPES = [ const INDEX_TYPES = [
@@ -190,11 +191,16 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient,
const {channelRepository} = getWorkerDependencies(); const {channelRepository} = getWorkerDependencies();
const guildId = createGuildID(BigInt(payload.guild_id!)); const guildId = createGuildID(BigInt(payload.guild_id!));
const searchService = requireSearchService<IMessageSearchService>(getMessageSearchService()); const searchService = requireSearchService<IMessageSearchService>(getMessageSearchService());
await searchService.deleteGuildMessages(guildId);
const channels = await channelRepository.listGuildChannels(guildId); const channels = await channelRepository.listGuildChannels(guildId);
if (channels.length === 0) { if (channels.length === 0) {
return 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`; const completionKey = `bulk_reindex:${payload.job_id}:remaining`;
await kvClient.del(completionKey); await kvClient.del(completionKey);
for (const channel of channels) { for (const channel of channels) {