From c5337afb865abd5f7b957cb4ab6edf57d95bf91c Mon Sep 17 00:00:00 2001 From: David Alecrim Date: Tue, 26 Sep 2023 14:33:07 +0100 Subject: [PATCH] feat: file type validation (#191) --- packages/api/src/chats/chats.controller.ts | 3 ++ .../document-type-mismatch.exception.ts | 36 +++++++++++++++++++ .../upload-documents-to-chat.usecase.ts | 31 ++++++++++++---- packages/api/src/common/constants/files.ts | 29 +++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/chats/exceptions/document-type-mismatch.exception.ts diff --git a/packages/api/src/chats/chats.controller.ts b/packages/api/src/chats/chats.controller.ts index 598df8f..0ab2f26 100644 --- a/packages/api/src/chats/chats.controller.ts +++ b/packages/api/src/chats/chats.controller.ts @@ -7,6 +7,7 @@ import { RemoveDocumentFromChatRequestDto } from '@/chats/dtos/remove-document-f import { ChatNotFoundExceptionSchema } from '@/chats/exceptions/chat-not-found.exception'; import { DocumentNotFoundExceptionSchema } from '@/chats/exceptions/document-not-found.exception'; import { DocumentPermissionsMismatchExceptionSchema } from '@/chats/exceptions/document-permissions-mismatch.exception'; +import { DocumentTypeMismatchExceptionSchema } from '@/chats/exceptions/document-type-mismatch.exception'; import { MaxDocumentSizeLimitExceptionSchema } from '@/chats/exceptions/max-document-size-limit.exception'; import { FindChatByRoomIdUsecase } from '@/chats/usecases/find-chat-by-room-id.usecase'; import { FindChatMessageHistoryByRoomIdUsecase } from '@/chats/usecases/find-chat-message-history-by-room-id.usecase'; @@ -41,6 +42,7 @@ import { ApiConflictResponse, ApiConsumes, ApiNoContentResponse, + ApiNotAcceptableResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -146,6 +148,7 @@ export class ChatsController { @ApiNoContentResponse({ description: 'No content' }) @ApiBadRequestResponse({ schema: MaxDocumentSizeLimitExceptionSchema }) @ApiConflictResponse({ schema: DocumentPermissionsMismatchExceptionSchema }) + @ApiNotAcceptableResponse({ schema: DocumentTypeMismatchExceptionSchema }) @ApiNotFoundResponse({ schema: ChatNotFoundExceptionSchema }) @ApiOperation({ description: 'Upload documents into a chat' }) uploadDocumentsToChat( diff --git a/packages/api/src/chats/exceptions/document-type-mismatch.exception.ts b/packages/api/src/chats/exceptions/document-type-mismatch.exception.ts new file mode 100644 index 0000000..3037278 --- /dev/null +++ b/packages/api/src/chats/exceptions/document-type-mismatch.exception.ts @@ -0,0 +1,36 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export const DocumentTypeMismatchExceptionSchema = { + type: 'object', + properties: { + statusCode: { + type: 'number', + example: 406, + }, + message: { + type: 'array', + items: { + type: 'string', + example: 'document: type_mismatch', + }, + }, + error: { + type: 'string', + example: "Document type extension doesn't match its contents", + }, + }, + required: ['statusCode', 'message', 'error'], +}; + +export class DocumentTypeMismatchException extends HttpException { + constructor() { + super( + { + statusCode: HttpStatus.NOT_ACCEPTABLE, + message: ['document: type_mismatch'], + error: "Document type extension doesn't match its contents", + }, + HttpStatus.NOT_ACCEPTABLE + ); + } +} diff --git a/packages/api/src/chats/usecases/upload-documents-to-chat.usecase.ts b/packages/api/src/chats/usecases/upload-documents-to-chat.usecase.ts index 24a57bb..2c511a4 100644 --- a/packages/api/src/chats/usecases/upload-documents-to-chat.usecase.ts +++ b/packages/api/src/chats/usecases/upload-documents-to-chat.usecase.ts @@ -1,11 +1,14 @@ -import { AiService } from '@/ai/facades/ai.service'; -import { AppConfigService } from '@/app-config/app-config.service'; +import { readFileSync } from 'fs'; import { ClerkAuthUserProvider } from '@/auth/providers/clerk/clerk-auth-user.provider'; import { ChatsRepository } from '@/chats/chats.repository'; import { ChatNotFoundException } from '@/chats/exceptions/chat-not-found.exception'; import { DocumentPermissionsMismatchException } from '@/chats/exceptions/document-permissions-mismatch.exception'; +import { DocumentTypeMismatchException } from '@/chats/exceptions/document-type-mismatch.exception'; import { MaxDocumentSizeLimitException } from '@/chats/exceptions/max-document-size-limit.exception'; -import { ACCEPTED_FILE_SIZE_LIMIT } from '@/common/constants/files'; +import { + ACCEPTED_FILE_SIZE_LIMIT, + fileBufferSignatureByMimetypeMap, +} from '@/common/constants/files'; import { CHAT_DOCUMENT_UPLOAD_QUEUE } from '@/common/constants/queues'; import { createChatDocUploadJobFactory } from '@/common/jobs/chat-doc-upload.job'; import { Chat } from '@/common/types/chat'; @@ -21,9 +24,7 @@ export class UploadDocumentsToChatUsecase implements Usecase { private readonly chatsRepository: ChatsRepository, private readonly clerkAuthUserProvider: ClerkAuthUserProvider, @InjectQueue(CHAT_DOCUMENT_UPLOAD_QUEUE) - private readonly chatDocUploadQueue: Queue, - private readonly appConfigService: AppConfigService, - private readonly aiService: AiService + private readonly chatDocUploadQueue: Queue ) {} async execute( @@ -39,6 +40,8 @@ export class UploadDocumentsToChatUsecase implements Usecase { this.checkMaxDocumentsSizePerRoomInvariant(existingChat, files); + this.checkForValidDocumentTypesInvariant(files); + await this.checkDocumentRolesMatchUserRolesInvariant( existingChat, fileRoles @@ -79,6 +82,22 @@ export class UploadDocumentsToChatUsecase implements Usecase { } } + private checkForValidDocumentTypesInvariant(files: Express.Multer.File[]) { + for (const file of files) { + const buffer = readFileSync(`${file.destination}/${file.filename}`); + const fileSignatureVerifier = + fileBufferSignatureByMimetypeMap[file.mimetype]; + const fileSignature = buffer.toString( + 'hex', + 0, + fileSignatureVerifier.relevantBytes + ); + if (!fileSignatureVerifier.signature.includes(fileSignature)) { + throw new DocumentTypeMismatchException(); + } + } + } + private async checkDocumentRolesMatchUserRolesInvariant( chat: Chat, fileRoles: string[] diff --git a/packages/api/src/common/constants/files.ts b/packages/api/src/common/constants/files.ts index ed6ed35..66acad6 100644 --- a/packages/api/src/common/constants/files.ts +++ b/packages/api/src/common/constants/files.ts @@ -16,6 +16,35 @@ export const acceptedFileMimetypes = [ DOCX_MIMETYPE, ]; +export const PDF_BUFFER_SIGNATURE = '25504446'; // 4 relevant bytes +export const TXT_UTF8_BUFFER_SIGNATURE = 'EFBBBF'; // 3 relevant bytes +export const TXT_BUFFER_SIGNATURE = '546869'; // 3 relevant bytes +export const DOCX_BUFFER_SIGNATURE = '504b0304'; // 4 relevant bytes + +export const acceptedFileBufferSignatures = [ + PDF_BUFFER_SIGNATURE, + TXT_UTF8_BUFFER_SIGNATURE, + TXT_BUFFER_SIGNATURE, + DOCX_BUFFER_SIGNATURE, +]; + +export const fileBufferSignatureByMimetypeMap: { + [key: string]: { relevantBytes: number; signature: string | string[] }; +} = { + [PDF_MIMETYPE]: { + relevantBytes: 4, + signature: [PDF_BUFFER_SIGNATURE], + }, + [TEXT_MIMETYPE]: { + relevantBytes: 3, + signature: [TXT_UTF8_BUFFER_SIGNATURE, TXT_BUFFER_SIGNATURE], + }, + [DOCX_MIMETYPE]: { + relevantBytes: 4, + signature: [DOCX_BUFFER_SIGNATURE], + }, +}; + export function sanitizeFilename(filename: string): string { const roomIdLength = 24; const maxCollectionNameLength = 62;