Skip to content

Commit dba1ba1

Browse files
fix(api): enforce attachment upload provenance (#1578)
1 parent f7324ee commit dba1ba1

10 files changed

Lines changed: 162 additions & 34 deletions

fluxer_api/src/api/Validator.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,23 @@ type ValidatorOptions<
144144
post?: Hook<T, E, P, Target, V>;
145145
};
146146

147+
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
148+
const errors: Array<ValidationError> = [];
149+
const localizedErrors: Array<LocalizedValidationError> = [];
150+
const seen = new Set<string>();
151+
for (const issue of issues) {
152+
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
153+
const code = getValidationErrorCode(issue.message);
154+
const key = `${path}|${code}`;
155+
if (seen.has(key)) continue;
156+
seen.add(key);
157+
const variables = extractVariablesFromIssue(issue);
158+
errors.push({path, message: code, code});
159+
localizedErrors.push({path, code, variables});
160+
}
161+
return new InputValidationError(errors, localizedErrors);
162+
}
163+
147164
export const Validator = <
148165
T extends ZodTypeAny,
149166
Target extends keyof ValidationTargets,
@@ -246,20 +263,7 @@ export const Validator = <
246263
}
247264
}
248265
if (!result.success) {
249-
const errors: Array<ValidationError> = [];
250-
const localizedErrors: Array<LocalizedValidationError> = [];
251-
const seen = new Set<string>();
252-
for (const issue of result.error.issues) {
253-
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
254-
const code = getValidationErrorCode(issue.message);
255-
const key = `${path}|${code}`;
256-
if (seen.has(key)) continue;
257-
seen.add(key);
258-
const variables = extractVariablesFromIssue(issue);
259-
errors.push({path, message: code, code});
260-
localizedErrors.push({path, code, variables});
261-
}
262-
throw new InputValidationError(errors, localizedErrors);
266+
throw inputValidationErrorFromZodIssues(result.error.issues);
263267
}
264268
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
265269
await next();

fluxer_api/src/api/channel/repositories/message/AttachmentUploadTraceRepository.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ interface MarkAttachmentUploadCompletedInput {
3636
completedAt?: Date;
3737
}
3838

39+
interface GetPendingAttachmentUploadInput {
40+
uploadKey: string;
41+
userId: UserID;
42+
channelId: ChannelID;
43+
uploadMode?: AttachmentUploadMode;
44+
}
45+
3946
export class AttachmentUploadTraceRepository {
4047
async getByUploadKey(uploadKey: string): Promise<AttachmentUploadTraceByKeyRow | null> {
4148
return await fetchOne<AttachmentUploadTraceByKeyRow>(GET_UPLOAD_TRACE_BY_KEY_QUERY.bind({upload_key: uploadKey}));
@@ -47,6 +54,20 @@ export class AttachmentUploadTraceRepository {
4754
);
4855
}
4956

57+
async getPendingUpload(input: GetPendingAttachmentUploadInput): Promise<AttachmentUploadTraceByKeyRow | null> {
58+
const existing = await this.getByUploadKey(input.uploadKey);
59+
if (
60+
!existing ||
61+
existing.user_id !== input.userId ||
62+
existing.channel_id !== input.channelId ||
63+
existing.attachment_id != null ||
64+
(input.uploadMode !== undefined && existing.upload_mode !== input.uploadMode)
65+
) {
66+
return null;
67+
}
68+
return existing;
69+
}
70+
5071
async recordRequestedUpload(input: RecordAttachmentUploadRequestInput): Promise<AttachmentUploadTraceByKeyRow> {
5172
const now = input.requestedAt ?? new Date();
5273
const row: AttachmentUploadTraceByKeyRow = {
@@ -89,7 +110,7 @@ export class AttachmentUploadTraceRepository {
89110
attachmentId: AttachmentID,
90111
): Promise<AttachmentUploadTraceByAttachmentRow | null> {
91112
const existing = await this.getByUploadKey(uploadKey);
92-
if (!existing) {
113+
if (!existing || existing.attachment_id != null) {
93114
return null;
94115
}
95116
const now = new Date();

fluxer_api/src/api/channel/services/AttachmentUploadService.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,20 @@ export class AttachmentUploadService {
278278
await this.getUploadPermissionAndLimit({userId, channelId});
279279
const bucket = Config.s3.buckets.uploads;
280280
return Promise.all(
281-
uploads.map(async ({upload_filename, upload_id}) => {
281+
uploads.map(async ({upload_filename, upload_id}, index) => {
282+
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
283+
uploadKey: upload_filename,
284+
userId,
285+
channelId,
286+
uploadMode: 'presigned_multipart',
287+
});
288+
if (!pendingUpload) {
289+
throw InputValidationError.fromCode(
290+
`uploads.${index}.upload_filename`,
291+
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
292+
{filename: upload_filename},
293+
);
294+
}
282295
const parts = await runAttachmentStorageOperation(() =>
283296
this.storageService.listParts({
284297
bucket,

fluxer_api/src/api/channel/services/ChannelService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export class ChannelService {
142142
favoriteMemeRepository,
143143
guildAuditLogService,
144144
messagePersistenceService,
145+
attachmentUploadTraceRepository,
145146
limitConfigService,
146147
directMessageSpamMitigationService,
147148
);

fluxer_api/src/api/channel/services/MessageService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
1818
import type {DirectMessageSpamMitigationService} from '../../user/services/DirectMessageSpamMitigationService';
1919
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
2020
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
21+
import type {AttachmentUploadTraceRepository} from '../repositories/message/AttachmentUploadTraceRepository';
2122
import {MessageAnonymizationService} from './message/MessageAnonymizationService';
2223
import {MessageChannelAuthService} from './message/MessageChannelAuthService';
2324
import {MessageDeleteService} from './message/MessageDeleteService';
@@ -66,6 +67,7 @@ export class MessageService {
6667
favoriteMemeRepository: IFavoriteMemeRepository,
6768
guildAuditLogService: GuildAuditLogService,
6869
persistenceService: MessagePersistenceService,
70+
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
6971
limitConfigService: LimitConfigService,
7072
directMessageSpamMitigationService: DirectMessageSpamMitigationService,
7173
) {
@@ -125,6 +127,7 @@ export class MessageService {
125127
processingService: this.processing,
126128
dispatchService: this.dispatch,
127129
embedAttachmentResolver: this.persistence.getEmbedAttachmentResolver(),
130+
attachmentUploadTraceRepository,
128131
operationsHelpers,
129132
limitConfigService,
130133
directMessageSpamMitigationService,

fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
1212
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
1313
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
1414
import {temporaryFile} from 'tempy';
15-
import {createAttachmentID} from '../../../BrandedTypes';
15+
import {createAttachmentID, type UserID} from '../../../BrandedTypes';
1616
import {Config} from '../../../Config';
1717
import type {MessageAttachment} from '../../../database/types/MessageTypes';
1818
import {contentModerationService, type ModerationContext} from '../../../infrastructure/ContentModerationService';
@@ -54,6 +54,7 @@ interface ProcessAttachmentParams {
5454
message: Message;
5555
attachment: AttachmentToProcess;
5656
index: number;
57+
uploadUserId: UserID;
5758
channel?: Channel;
5859
guild?: GuildResponse | null;
5960
member?: GuildMemberResponse | null;
@@ -88,6 +89,7 @@ export class AttachmentProcessingService {
8889
async computeAttachments(params: {
8990
message: Message;
9091
attachments: Array<AttachmentToProcess>;
92+
uploadUserId: UserID;
9193
channel?: Channel;
9294
guild?: GuildResponse | null;
9395
member?: GuildMemberResponse | null;
@@ -105,6 +107,7 @@ export class AttachmentProcessingService {
105107
message: params.message,
106108
attachment,
107109
index,
110+
uploadUserId: params.uploadUserId,
108111
channel: params.channel,
109112
guild: params.guild,
110113
member: params.member,
@@ -132,26 +135,30 @@ export class AttachmentProcessingService {
132135
}
133136
}),
134137
);
135-
await Promise.all(
136-
results.map(async (result) => {
137-
const bound = await this.attachmentUploadTraceRepository.bindAttachment(
138+
const bindingResults = await Promise.all(
139+
results.map(async (result, index) => ({
140+
index,
141+
result,
142+
bound: await this.attachmentUploadTraceRepository.bindAttachment(
138143
result.copyOperation.sourceKey,
139144
result.attachment.attachment_id,
140-
);
141-
if (!bound) {
142-
Logger.warn(
143-
{
144-
attachmentId: result.attachment.attachment_id.toString(),
145-
uploadKey: result.copyOperation.sourceKey,
146-
},
147-
'Missing attachment upload trace while binding processed attachment',
148-
);
149-
}
150-
}),
145+
),
146+
})),
151147
);
152148
for (const result of results) {
153149
void this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
154150
}
151+
const unboundResult = bindingResults.find(({bound}) => bound === null);
152+
if (unboundResult) {
153+
for (const result of results) {
154+
this.deleteUploadObject(result.copyOperation.destinationBucket, result.copyOperation.destinationKey);
155+
}
156+
throw InputValidationError.fromCode(
157+
`attachments.${unboundResult.index}.upload_filename`,
158+
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
159+
{filename: unboundResult.result.attachment.filename},
160+
);
161+
}
155162
const processedAttachments: Array<MessageAttachment> = results.map((result, index) => {
156163
const finalObject = copyResults[index];
157164
if (result.applyFinalObjectMetadata && finalObject) {
@@ -171,6 +178,18 @@ export class AttachmentProcessingService {
171178

172179
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
173180
const {message, attachment, index, nsfwMode} = params;
181+
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
182+
uploadKey: attachment.upload_filename,
183+
userId: params.uploadUserId,
184+
channelId: message.channelId,
185+
});
186+
if (!pendingUpload) {
187+
throw InputValidationError.fromCode(
188+
`attachments.${index}.upload_filename`,
189+
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
190+
{filename: attachment.filename},
191+
);
192+
}
174193
const uploadedFile = await this.storageService.getObjectMetadata(
175194
Config.s3.buckets.uploads,
176195
attachment.upload_filename,

fluxer_api/src/api/channel/services/message/MessageEditService.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,24 @@ export class MessageEditService {
6666
userId,
6767
channelId,
6868
});
69-
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
69+
const hasNewAttachments =
70+
data.attachments?.some(
71+
(attachment) =>
72+
'upload_filename' in attachment &&
73+
typeof attachment.upload_filename === 'string' &&
74+
attachment.upload_filename.length > 0,
75+
) ?? false;
76+
const [canEmbedLinks, canMentionEveryone, canAttachFiles] = await Promise.all([
7077
hasPermission(Permissions.EMBED_LINKS),
7178
hasPermission(Permissions.MENTION_EVERYONE),
79+
hasPermission(Permissions.ATTACH_FILES),
7280
]);
7381
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
7482
throw new MissingPermissionsError();
7583
}
84+
if (hasNewAttachments && !canAttachFiles) {
85+
throw new MissingPermissionsError();
86+
}
7687
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
7788
throw new FeatureTemporarilyDisabledError();
7889
}
@@ -155,6 +166,7 @@ export class MessageEditService {
155166
channel,
156167
guild,
157168
member,
169+
attachmentUploadUserId: userId,
158170
allowEmbeds: canEmbedLinks,
159171
isBot: user?.isBot,
160172
isBugHunterBot,

fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3+
import assert from 'node:assert/strict';
34
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
45
import {UserFlags} from '@fluxer/constants/src/UserConstants';
56
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -84,6 +85,7 @@ interface CreateMessageParams {
8485
flags: number;
8586
embeds?: Array<RichEmbedRequest>;
8687
attachments?: Array<AttachmentToProcess>;
88+
attachmentUploadUserId?: UserID;
8789
processedAttachments?: Array<MessageAttachment>;
8890
stickerIds?: Array<StickerID>;
8991
messageReference?: MessageReference;
@@ -292,12 +294,18 @@ export class MessagePersistenceService {
292294
if (!params.attachments || params.attachments.length === 0) {
293295
return null;
294296
}
297+
const uploadUserId = params.attachmentUploadUserId;
298+
assert(
299+
uploadUserId !== undefined,
300+
'Attachment upload actor must be resolved before processing new attachments',
301+
);
295302
return this.attachmentService.computeAttachments({
296303
message: {
297304
id: params.messageId,
298305
channelId: params.channelId,
299306
} as Message,
300307
attachments: params.attachments,
308+
uploadUserId,
301309
channel: params.channel,
302310
guild: params.guild,
303311
member: params.member,
@@ -412,6 +420,7 @@ export class MessagePersistenceService {
412420
guild: GuildResponse | null;
413421
member?: GuildMemberResponse | null;
414422
allowEmbeds?: boolean;
423+
attachmentUploadUserId?: UserID;
415424
isBot?: boolean;
416425
isBugHunterBot?: boolean;
417426
locale?: string | null;
@@ -493,9 +502,15 @@ export class MessagePersistenceService {
493502
}
494503
let processedNewAttachments: Array<MessageAttachment> = [];
495504
if (newAttachments.length > 0) {
505+
const uploadUserId = params.attachmentUploadUserId;
506+
assert(
507+
uploadUserId !== undefined,
508+
'Attachment upload actor must be resolved before processing new attachments',
509+
);
496510
const attachmentResult = await this.attachmentService.computeAttachments({
497511
message,
498512
attachments: newAttachments,
513+
uploadUserId,
499514
channel,
500515
guild,
501516
member,

fluxer_api/src/api/channel/services/message/MessageRequestParser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'
2020
import type {User} from '../../../models/User';
2121
import type {HonoEnv} from '../../../types/HonoEnv';
2222
import {parseJsonPreservingLargeIntegers} from '../../../utils/LosslessJsonParser';
23+
import {inputValidationErrorFromZodIssues} from '../../../Validator';
2324
import {type AttachmentRequestData, mergeUploadWithClientData, type UploadedAttachment} from '../../AttachmentDTOs';
2425
import type {IChannelRepository} from '../../IChannelRepository';
2526
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
@@ -54,7 +55,7 @@ export async function parseMultipartMessageData(
5455
options?.onPayloadParsed?.(mergedJsonData);
5556
const validationResult = schema.safeParse(mergedJsonData);
5657
if (!validationResult.success) {
57-
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
58+
throw inputValidationErrorFromZodIssues(validationResult.error.issues);
5859
}
5960
const data = validationResult.data as Partial<MessageRequest> &
6061
Partial<MessageUpdateRequest> & {

0 commit comments

Comments
 (0)