fix(api): enforce attachment upload provenance (#1578)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 14:49:46 +02:00
1 parent f7324ee73c
commit dba1ba1112
10 files changed
+162 -34

No files matched your search

+18 -14
View File
@@ -144,6 +144,23 @@ type ValidatorOptions<
post?: Hook<T, E, P, Target, V>;
};
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
const seen = new Set<string>();
for (const issue of issues) {
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
const code = getValidationErrorCode(issue.message);
const key = `${path}|${code}`;
if (seen.has(key)) continue;
seen.add(key);
const variables = extractVariablesFromIssue(issue);
errors.push({path, message: code, code});
localizedErrors.push({path, code, variables});
}
return new InputValidationError(errors, localizedErrors);
}
export const Validator = <
T extends ZodTypeAny,
Target extends keyof ValidationTargets,
@@ -246,20 +263,7 @@ export const Validator = <
}
}
if (!result.success) {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
const seen = new Set<string>();
for (const issue of result.error.issues) {
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
const code = getValidationErrorCode(issue.message);
const key = `${path}|${code}`;
if (seen.has(key)) continue;
seen.add(key);
const variables = extractVariablesFromIssue(issue);
errors.push({path, message: code, code});
localizedErrors.push({path, code, variables});
}
throw new InputValidationError(errors, localizedErrors);
throw inputValidationErrorFromZodIssues(result.error.issues);
}
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
await next();
@@ -36,6 +36,13 @@ interface MarkAttachmentUploadCompletedInput {
completedAt?: Date;
}
interface GetPendingAttachmentUploadInput {
uploadKey: string;
userId: UserID;
channelId: ChannelID;
uploadMode?: AttachmentUploadMode;
}
export class AttachmentUploadTraceRepository {
async getByUploadKey(uploadKey: string): Promise<AttachmentUploadTraceByKeyRow | null> {
return await fetchOne<AttachmentUploadTraceByKeyRow>(GET_UPLOAD_TRACE_BY_KEY_QUERY.bind({upload_key: uploadKey}));
@@ -47,6 +54,20 @@ export class AttachmentUploadTraceRepository {
);
}
async getPendingUpload(input: GetPendingAttachmentUploadInput): Promise<AttachmentUploadTraceByKeyRow | null> {
const existing = await this.getByUploadKey(input.uploadKey);
if (
!existing ||
existing.user_id !== input.userId ||
existing.channel_id !== input.channelId ||
existing.attachment_id != null ||
(input.uploadMode !== undefined && existing.upload_mode !== input.uploadMode)
) {
return null;
}
return existing;
}
async recordRequestedUpload(input: RecordAttachmentUploadRequestInput): Promise<AttachmentUploadTraceByKeyRow> {
const now = input.requestedAt ?? new Date();
const row: AttachmentUploadTraceByKeyRow = {
@@ -89,7 +110,7 @@ export class AttachmentUploadTraceRepository {
attachmentId: AttachmentID,
): Promise<AttachmentUploadTraceByAttachmentRow | null> {
const existing = await this.getByUploadKey(uploadKey);
if (!existing) {
if (!existing || existing.attachment_id != null) {
return null;
}
const now = new Date();
@@ -278,7 +278,20 @@ export class AttachmentUploadService {
await this.getUploadPermissionAndLimit({userId, channelId});
const bucket = Config.s3.buckets.uploads;
return Promise.all(
uploads.map(async ({upload_filename, upload_id}) => {
uploads.map(async ({upload_filename, upload_id}, index) => {
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
uploadKey: upload_filename,
userId,
channelId,
uploadMode: 'presigned_multipart',
});
if (!pendingUpload) {
throw InputValidationError.fromCode(
`uploads.${index}.upload_filename`,
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
{filename: upload_filename},
);
}
const parts = await runAttachmentStorageOperation(() =>
this.storageService.listParts({
bucket,
@@ -142,6 +142,7 @@ export class ChannelService {
favoriteMemeRepository,
guildAuditLogService,
messagePersistenceService,
attachmentUploadTraceRepository,
limitConfigService,
directMessageSpamMitigationService,
);
@@ -18,6 +18,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
import type {DirectMessageSpamMitigationService} from '../../user/services/DirectMessageSpamMitigationService';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import type {AttachmentUploadTraceRepository} from '../repositories/message/AttachmentUploadTraceRepository';
import {MessageAnonymizationService} from './message/MessageAnonymizationService';
import {MessageChannelAuthService} from './message/MessageChannelAuthService';
import {MessageDeleteService} from './message/MessageDeleteService';
@@ -66,6 +67,7 @@ export class MessageService {
favoriteMemeRepository: IFavoriteMemeRepository,
guildAuditLogService: GuildAuditLogService,
persistenceService: MessagePersistenceService,
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
limitConfigService: LimitConfigService,
directMessageSpamMitigationService: DirectMessageSpamMitigationService,
) {
@@ -125,6 +127,7 @@ export class MessageService {
processingService: this.processing,
dispatchService: this.dispatch,
embedAttachmentResolver: this.persistence.getEmbedAttachmentResolver(),
attachmentUploadTraceRepository,
operationsHelpers,
limitConfigService,
directMessageSpamMitigationService,
@@ -12,7 +12,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
import {temporaryFile} from 'tempy';
import {createAttachmentID} from '../../../BrandedTypes';
import {createAttachmentID, type UserID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import type {MessageAttachment} from '../../../database/types/MessageTypes';
import {contentModerationService, type ModerationContext} from '../../../infrastructure/ContentModerationService';
@@ -54,6 +54,7 @@ interface ProcessAttachmentParams {
message: Message;
attachment: AttachmentToProcess;
index: number;
uploadUserId: UserID;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
@@ -88,6 +89,7 @@ export class AttachmentProcessingService {
async computeAttachments(params: {
message: Message;
attachments: Array<AttachmentToProcess>;
uploadUserId: UserID;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
@@ -105,6 +107,7 @@ export class AttachmentProcessingService {
message: params.message,
attachment,
index,
uploadUserId: params.uploadUserId,
channel: params.channel,
guild: params.guild,
member: params.member,
@@ -132,26 +135,30 @@ export class AttachmentProcessingService {
}
}),
);
await Promise.all(
results.map(async (result) => {
const bound = await this.attachmentUploadTraceRepository.bindAttachment(
const bindingResults = await Promise.all(
results.map(async (result, index) => ({
index,
result,
bound: await this.attachmentUploadTraceRepository.bindAttachment(
result.copyOperation.sourceKey,
result.attachment.attachment_id,
);
if (!bound) {
Logger.warn(
{
attachmentId: result.attachment.attachment_id.toString(),
uploadKey: result.copyOperation.sourceKey,
},
'Missing attachment upload trace while binding processed attachment',
);
}
}),
),
})),
);
for (const result of results) {
void this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
const unboundResult = bindingResults.find(({bound}) => bound === null);
if (unboundResult) {
for (const result of results) {
this.deleteUploadObject(result.copyOperation.destinationBucket, result.copyOperation.destinationKey);
}
throw InputValidationError.fromCode(
`attachments.${unboundResult.index}.upload_filename`,
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
{filename: unboundResult.result.attachment.filename},
);
}
const processedAttachments: Array<MessageAttachment> = results.map((result, index) => {
const finalObject = copyResults[index];
if (result.applyFinalObjectMetadata && finalObject) {
@@ -171,6 +178,18 @@ export class AttachmentProcessingService {
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
const {message, attachment, index, nsfwMode} = params;
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
uploadKey: attachment.upload_filename,
userId: params.uploadUserId,
channelId: message.channelId,
});
if (!pendingUpload) {
throw InputValidationError.fromCode(
`attachments.${index}.upload_filename`,
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
{filename: attachment.filename},
);
}
const uploadedFile = await this.storageService.getObjectMetadata(
Config.s3.buckets.uploads,
attachment.upload_filename,
@@ -66,13 +66,24 @@ export class MessageEditService {
userId,
channelId,
});
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
const hasNewAttachments =
data.attachments?.some(
(attachment) =>
'upload_filename' in attachment &&
typeof attachment.upload_filename === 'string' &&
attachment.upload_filename.length > 0,
) ?? false;
const [canEmbedLinks, canMentionEveryone, canAttachFiles] = await Promise.all([
hasPermission(Permissions.EMBED_LINKS),
hasPermission(Permissions.MENTION_EVERYONE),
hasPermission(Permissions.ATTACH_FILES),
]);
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if (hasNewAttachments && !canAttachFiles) {
throw new MissingPermissionsError();
}
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
}
@@ -155,6 +166,7 @@ export class MessageEditService {
channel,
guild,
member,
attachmentUploadUserId: userId,
allowEmbeds: canEmbedLinks,
isBot: user?.isBot,
isBugHunterBot,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -84,6 +85,7 @@ interface CreateMessageParams {
flags: number;
embeds?: Array<RichEmbedRequest>;
attachments?: Array<AttachmentToProcess>;
attachmentUploadUserId?: UserID;
processedAttachments?: Array<MessageAttachment>;
stickerIds?: Array<StickerID>;
messageReference?: MessageReference;
@@ -292,12 +294,18 @@ export class MessagePersistenceService {
if (!params.attachments || params.attachments.length === 0) {
return null;
}
const uploadUserId = params.attachmentUploadUserId;
assert(
uploadUserId !== undefined,
'Attachment upload actor must be resolved before processing new attachments',
);
return this.attachmentService.computeAttachments({
message: {
id: params.messageId,
channelId: params.channelId,
} as Message,
attachments: params.attachments,
uploadUserId,
channel: params.channel,
guild: params.guild,
member: params.member,
@@ -412,6 +420,7 @@ export class MessagePersistenceService {
guild: GuildResponse | null;
member?: GuildMemberResponse | null;
allowEmbeds?: boolean;
attachmentUploadUserId?: UserID;
isBot?: boolean;
isBugHunterBot?: boolean;
locale?: string | null;
@@ -493,9 +502,15 @@ export class MessagePersistenceService {
}
let processedNewAttachments: Array<MessageAttachment> = [];
if (newAttachments.length > 0) {
const uploadUserId = params.attachmentUploadUserId;
assert(
uploadUserId !== undefined,
'Attachment upload actor must be resolved before processing new attachments',
);
const attachmentResult = await this.attachmentService.computeAttachments({
message,
attachments: newAttachments,
uploadUserId,
channel,
guild,
member,
@@ -20,6 +20,7 @@ import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'
import type {User} from '../../../models/User';
import type {HonoEnv} from '../../../types/HonoEnv';
import {parseJsonPreservingLargeIntegers} from '../../../utils/LosslessJsonParser';
import {inputValidationErrorFromZodIssues} from '../../../Validator';
import {type AttachmentRequestData, mergeUploadWithClientData, type UploadedAttachment} from '../../AttachmentDTOs';
import type {IChannelRepository} from '../../IChannelRepository';
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
@@ -54,7 +55,7 @@ export async function parseMultipartMessageData(
options?.onPayloadParsed?.(mergedJsonData);
const validationResult = schema.safeParse(mergedJsonData);
if (!validationResult.success) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
throw inputValidationErrorFromZodIssues(validationResult.error.issues);
}
const data = validationResult.data as Partial<MessageRequest> &
Partial<MessageUpdateRequest> & {
@@ -53,6 +53,7 @@ import {assertGuildMemberCanCommunicate} from '../../../utils/GuildCommunication
import type {AttachmentRequestData, AttachmentToProcess} from '../../AttachmentDTOs';
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import type {AttachmentUploadTraceRepository} from '../../repositories/message/AttachmentUploadTraceRepository';
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
import type {MessageChannelAuthService} from './MessageChannelAuthService';
import type {DmNsfwContext} from './MessageContentService';
@@ -88,6 +89,7 @@ interface MessageSendServiceDeps {
dispatchService: MessageDispatchService;
operationsHelpers: MessageOperationsHelpers;
embedAttachmentResolver: MessageEmbedAttachmentResolver;
attachmentUploadTraceRepository: AttachmentUploadTraceRepository;
limitConfigService: LimitConfigService;
directMessageSpamMitigationService: DirectMessageSpamMitigationService;
}
@@ -204,6 +206,17 @@ export class MessageSendService {
return processed.length > 0 ? processed : undefined;
}
private resolveWebhookAttachmentUploadUserId(
webhook: Webhook,
attachments?: Array<AttachmentRequestData>,
): UserID | undefined {
const uploadUserId = webhook.creatorId ?? undefined;
if (uploadUserId === undefined && this.attachmentsToProcess(attachments) !== undefined) {
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
return uploadUserId;
}
private getOneToOneDmRecipientId(channel: Channel, senderId: UserID): UserID | null {
if (channel.guildId || channel.type !== ChannelTypes.DM) {
return null;
@@ -241,10 +254,14 @@ export class MessageSendService {
hasPermission(Permissions.ATTACH_FILES),
]);
const hasFavoriteMeme = data.favorite_meme_id != null;
const hasUploadedAttachments = this.attachmentsToProcess(data.attachments) !== undefined;
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if (hasFavoriteMeme && (!canEmbedLinks || !canAttachFiles)) {
if (hasFavoriteMeme && !canEmbedLinks) {
throw new MissingPermissionsError();
}
if ((hasFavoriteMeme || hasUploadedAttachments) && !canAttachFiles) {
throw new MissingPermissionsError();
}
if (guild) {
@@ -369,6 +386,7 @@ export class MessageSendService {
await this.ensureAttachmentsExist({
attachments: data.attachments,
user,
channelId,
guildFeatures: guild?.features ?? null,
});
}
@@ -435,6 +453,7 @@ export class MessageSendService {
await this.ensureAttachmentsExist({
attachments: data.attachments,
user,
channelId,
guildFeatures: null,
});
}
@@ -481,10 +500,12 @@ export class MessageSendService {
private async ensureAttachmentsExist({
attachments,
user,
channelId,
guildFeatures,
}: {
attachments?: Array<AttachmentRequestData>;
user: User;
channelId: ChannelID;
guildFeatures: Iterable<string> | null;
}): Promise<void> {
if (!attachments || attachments.length === 0) return;
@@ -494,6 +515,18 @@ export class MessageSendService {
for (let index = 0; index < attachments.length; index++) {
const attachment = attachments[index];
if (!('upload_filename' in attachment) || !attachment.upload_filename) continue;
const pendingUpload = await this.deps.attachmentUploadTraceRepository.getPendingUpload({
uploadKey: attachment.upload_filename,
userId: user.id,
channelId,
});
if (!pendingUpload) {
throw InputValidationError.fromCode(
`attachments.${index}.upload_filename`,
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
{filename: attachment.filename},
);
}
const metadata = await this.deps.storageService.getObjectMetadata(
Config.s3.buckets.uploads,
attachment.upload_filename,
@@ -832,6 +865,7 @@ export class MessageSendService {
await this.ensureAttachmentsExist({
attachments: data.attachments,
user,
channelId,
guildFeatures: guild?.features ?? null,
});
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
@@ -920,6 +954,7 @@ export class MessageSendService {
flags: this.deps.validationService.calculateMessageFlags(data),
embeds: data.embeds,
attachments: attachmentsToProcess,
attachmentUploadUserId: user.id,
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
messageReference,
@@ -1139,6 +1174,7 @@ export class MessageSendService {
flags: this.deps.validationService.calculateMessageFlags(data),
embeds: data.embeds,
attachments: this.attachmentsToProcess(data.attachments),
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
messageReference,
messageSnapshots,
guildId: channel.guildId,
@@ -1220,6 +1256,7 @@ export class MessageSendService {
data,
channel,
guild,
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
allowEmbeds: true,
});
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
@@ -1268,6 +1305,7 @@ export class MessageSendService {
await this.ensureAttachmentsExist({
attachments: data.attachments,
user,
channelId,
guildFeatures: null,
});
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
@@ -1292,6 +1330,7 @@ export class MessageSendService {
flags: data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0,
embeds: data.embeds,
attachments: attachmentsToProcess,
attachmentUploadUserId: user.id,
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
messageReference,
messageSnapshots,