diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts index 47139064fcc..1d6889ea684 100644 --- a/packages/common/pipes/file/file-type.validator.ts +++ b/packages/common/pipes/file/file-type.validator.ts @@ -10,6 +10,12 @@ export type FileTypeValidatorOptions = { * @default false */ skipMagicNumbersValidation?: boolean; + + /** + * If `true`, and magic number check fails, fallback to mimetype comparison. + * @default false + */ + fallbackToMimetype?: boolean; }; /** @@ -26,10 +32,26 @@ export class FileTypeValidator extends FileValidator< IFile > { buildErrorMessage(file?: IFile): string { + const expected = this.validationOptions.fileType; + if (file?.mimetype) { - return `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`; + const baseMessage = `Validation failed (current file type is ${file.mimetype}, expected type is ${expected})`; + + /** + * If fallbackToMimetype is enabled, this means the validator failed to detect the file type + * via magic number inspection (e.g. due to an unknown or too short buffer), + * and instead used the mimetype string provided by the client as a fallback. + * + * This message clarifies that fallback logic was used, in case users rely on file signatures. + */ + if (this.validationOptions.fallbackToMimetype) { + return `${baseMessage} - magic number detection failed, used mimetype fallback`; + } + + return baseMessage; } - return `Validation failed (expected type is ${this.validationOptions.fileType})`; + + return `Validation failed (expected type is ${expected})`; } async isValid(file?: IFile): Promise { @@ -39,15 +61,14 @@ export class FileTypeValidator extends FileValidator< const isFileValid = !!file && 'mimetype' in file; + // Skip magic number validation if set if (this.validationOptions.skipMagicNumbersValidation) { return ( isFileValid && !!file.mimetype.match(this.validationOptions.fileType) ); } - if (!isFileValid || !file.buffer) { - return false; - } + if (!isFileValid || !file.buffer) return false; try { const { fileTypeFromBuffer } = (await eval( @@ -56,9 +77,20 @@ export class FileTypeValidator extends FileValidator< const fileType = await fileTypeFromBuffer(file.buffer); - return ( - !!fileType && !!fileType.mime.match(this.validationOptions.fileType) - ); + if (fileType) { + // Match detected mime type against allowed type + return !!fileType.mime.match(this.validationOptions.fileType); + } + + /** + * Fallback logic: If file-type cannot detect magic number (e.g. file too small), + * Optionally fall back to mimetype string for compatibility. + * This is useful for plain text, CSVs, or files without recognizable signatures. + */ + if (this.validationOptions.fallbackToMimetype) { + return !!file.mimetype.match(this.validationOptions.fileType); + } + return false; } catch { return false; } diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts index 8c39e0c5167..cdde3de895b 100644 --- a/packages/common/test/pipes/file/file-type.validator.spec.ts +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -186,6 +186,36 @@ describe('FileTypeValidator', () => { expect(await fileTypeValidator.isValid(requestFile)).to.equal(false); }); + + it('should return true when fallbackToMimetype is enabled and mimetype matches', async () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'text/plain', + fallbackToMimetype: true, + }); + + const shortText = Buffer.from('ok'); + const requestFile = { + mimetype: 'text/plain', + buffer: shortText, + } as IFile; + + expect(await fileTypeValidator.isValid(requestFile)).to.equal(true); + }); + + it('should return false when fallbackToMimetype is enabled but mimetype does not match', async () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'application/json', + fallbackToMimetype: true, + }); + + const shortText = Buffer.from('ok'); + const requestFile = { + mimetype: 'text/plain', + buffer: shortText, + } as IFile; + + expect(await fileTypeValidator.isValid(requestFile)).to.equal(false); + }); }); describe('buildErrorMessage', () => { @@ -279,5 +309,34 @@ describe('FileTypeValidator', () => { 'Validation failed (current file type is image/png, expected type is jpeg)', ); }); + + it('should return false for text file with small buffer and correct mimetype but fail magic number validation', async () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'text/plain', + }); + + const textBuffer = Buffer.from('hi'); // too short to identify + const requestFile = { + mimetype: 'text/plain', + buffer: textBuffer, + } as IFile; + + expect(await fileTypeValidator.isValid(requestFile)).to.equal(false); + }); + + it('should fail validation for text/csv when magic number detection is enabled', async () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'text/csv', + skipMagicNumbersValidation: false, + }); + + const csvFile = Buffer.from('name,age\nJohn,30'); + const requestFile = { + mimetype: 'text/csv', + buffer: csvFile, + } as IFile; + + expect(await fileTypeValidator.isValid(requestFile)).to.equal(false); + }); }); });