Compare commits

...
Author SHA1 Message Date
prastoin 314f72abf0 handle apple image format 2026-05-12 15:39:30 +02:00
prastoin d8b50851c9 lint 2026-05-12 15:21:14 +02:00
prastoin b6ce89794e isBufferLike type predicate 2026-05-12 14:50:14 +02:00
prastoin e8ca9d0c85 sanitization error 2026-05-12 14:47:20 +02:00
prastoin ec6a52c370 async sanitize file 2026-05-12 14:40:46 +02:00
prastoin 38fdc76388 sanitize file and unit test 2026-05-12 14:40:34 +02:00
9 changed files with 243 additions and 8 deletions
@@ -497,7 +497,11 @@ export class ApplicationInstallService {
file: content,
filename: relativePath,
});
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
const sanitizedContent = await sanitizeFile({
file: content,
ext,
mimeType,
});
await this.fileStorageService.writeFile({
sourceFile: sanitizedContent,
@@ -8,6 +8,7 @@ export enum FileStorageExceptionCode {
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
ACCESS_DENIED = 'ACCESS_DENIED',
INVALID_EXTENSION = 'INVALID_EXTENSION',
SANITIZATION_FAILED = 'SANITIZATION_FAILED',
}
const getFileStorageExceptionUserFriendlyMessage = (
@@ -20,6 +21,8 @@ const getFileStorageExceptionUserFriendlyMessage = (
return msg`File not found.`;
case FileStorageExceptionCode.ACCESS_DENIED:
return msg`Access denied.`;
case FileStorageExceptionCode.SANITIZATION_FAILED:
return msg`The image file could not be processed. It may be corrupted or in an unsupported format.`;
default:
assertUnreachable(code);
}
@@ -32,7 +32,7 @@ export class FileAiChatService {
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const sanitizedFile = await sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -73,7 +73,7 @@ export class FileCorePictureService {
queryRunner?: QueryRunner;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({ file, filename });
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const sanitizedFile = await sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -35,7 +35,7 @@ export class FileEmailAttachmentService {
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const sanitizedFile = await sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -33,7 +33,7 @@ export class FileWorkflowService {
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const sanitizedFile = await sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -58,7 +58,7 @@ export class FilesFieldService {
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const sanitizedFile = await sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -0,0 +1,190 @@
import sharp from 'sharp';
import { FileStorageException } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { sanitizeFile } from '../sanitize-file.utils';
// Minimal valid 1x1 JPEG with an EXIF block containing an ImageDescription tag
const createJpegWithExif = async (): Promise<Buffer> => {
return sharp({
create: { width: 1, height: 1, channels: 3, background: '#ff0000' },
})
.jpeg()
.withMetadata({
exif: {
IFD0: { ImageDescription: 'sensitive-location-data' },
},
})
.toBuffer();
};
describe('sanitizeFile', () => {
describe('raster image EXIF stripping', () => {
it('should strip EXIF metadata from JPEG files', async () => {
const jpegWithExif = await createJpegWithExif();
const originalMetadata = await sharp(jpegWithExif).metadata();
expect(originalMetadata.exif).toBeDefined();
const sanitized = await sanitizeFile({
file: jpegWithExif,
ext: 'jpg',
mimeType: 'image/jpeg',
});
expect(Buffer.isBuffer(sanitized)).toBe(true);
const sanitizedMetadata = await sharp(sanitized as Buffer).metadata();
expect(sanitizedMetadata.exif).toBeUndefined();
});
it('should strip EXIF metadata from PNG files', async () => {
const pngWithExif = await sharp({
create: { width: 1, height: 1, channels: 3, background: '#00ff00' },
})
.png()
.withMetadata({
exif: {
IFD0: { ImageDescription: 'test-metadata' },
},
})
.toBuffer();
const sanitized = await sanitizeFile({
file: pngWithExif,
ext: 'png',
mimeType: 'image/png',
});
expect(Buffer.isBuffer(sanitized)).toBe(true);
const sanitizedMetadata = await sharp(sanitized as Buffer).metadata();
expect(sanitizedMetadata.exif).toBeUndefined();
});
it('should strip EXIF metadata when file is a Uint8Array', async () => {
const jpegWithExif = await createJpegWithExif();
const uint8Array = new Uint8Array(jpegWithExif);
const sanitized = await sanitizeFile({
file: uint8Array,
ext: 'jpg',
mimeType: 'image/jpeg',
});
expect(Buffer.isBuffer(sanitized)).toBe(true);
const sanitizedMetadata = await sharp(sanitized as Buffer).metadata();
expect(sanitizedMetadata.exif).toBeUndefined();
});
it('should apply EXIF orientation before stripping metadata', async () => {
// Create a 2x1 image then tag it with orientation 6 (90° CW rotation)
const landscape = await sharp({
create: { width: 2, height: 1, channels: 3, background: '#0000ff' },
})
.jpeg()
.withMetadata({ orientation: 6 })
.toBuffer();
const sanitized = await sanitizeFile({
file: landscape,
ext: 'jpg',
mimeType: 'image/jpeg',
});
const sanitizedMetadata = await sharp(sanitized as Buffer).metadata();
// After applying orientation 6 (90° CW), a 2x1 image becomes 1x2
expect(sanitizedMetadata.width).toBe(1);
expect(sanitizedMetadata.height).toBe(2);
expect(sanitizedMetadata.exif).toBeUndefined();
});
it('should throw a FileStorageException when sharp cannot process a corrupted image', async () => {
const corruptedBuffer = Buffer.from('not-a-real-image');
await expect(
sanitizeFile({
file: corruptedBuffer,
ext: 'jpg',
mimeType: 'image/jpeg',
}),
).rejects.toThrow(FileStorageException);
});
});
describe('SVG sanitization', () => {
it('should sanitize SVG files with DOMPurify', async () => {
const maliciousSvg =
'<svg><script>alert("xss")</script><circle r="10"/></svg>';
const result = await sanitizeFile({
file: maliciousSvg,
ext: 'svg',
mimeType: 'image/svg+xml',
});
expect(typeof result).toBe('string');
expect(result).not.toContain('<script>');
expect(result).toContain('<circle');
});
it('should handle SVG passed as Buffer', async () => {
const svgBuffer = Buffer.from(
'<svg><script>alert("xss")</script><rect/></svg>',
);
const result = await sanitizeFile({
file: svgBuffer,
ext: 'svg',
mimeType: 'image/svg+xml',
});
expect(typeof result).toBe('string');
expect(result).not.toContain('<script>');
});
});
describe('non-image files', () => {
it('should pass through non-image files unmodified', async () => {
const textContent = Buffer.from('plain text content');
const result = await sanitizeFile({
file: textContent,
ext: 'txt',
mimeType: 'text/plain',
});
expect(result).toBe(textContent);
});
it('should pass through PDF files unmodified', async () => {
const pdfContent = Buffer.from('%PDF-1.4 fake pdf content');
const result = await sanitizeFile({
file: pdfContent,
ext: 'pdf',
mimeType: 'application/pdf',
});
expect(result).toBe(pdfContent);
});
it('should pass through files with undefined mime type', async () => {
const unknownContent = Buffer.from('unknown content');
const result = await sanitizeFile({
file: unknownContent,
ext: 'bin',
mimeType: undefined,
});
expect(result).toBe(unknownContent);
});
});
});
@@ -1,7 +1,27 @@
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import sharp from 'sharp';
export const sanitizeFile = ({
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
const SHARP_SUPPORTED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/tiff',
'image/avif',
'image/heif',
'image/heic',
]);
const isBufferLike = (
file: Buffer | Uint8Array | string,
): file is Buffer | Uint8Array => typeof file !== 'string';
export const sanitizeFile = async ({
file,
ext,
mimeType,
@@ -9,7 +29,7 @@ export const sanitizeFile = ({
file: Buffer | Uint8Array | string;
ext: string;
mimeType: string | undefined;
}): Buffer | Uint8Array | string => {
}): Promise<Buffer | Uint8Array | string> => {
if (ext === 'svg' || mimeType === 'image/svg+xml') {
const window = new JSDOM('').window;
const purify = DOMPurify(window);
@@ -27,5 +47,23 @@ export const sanitizeFile = ({
return purify.sanitize(fileString);
}
if (
mimeType &&
SHARP_SUPPORTED_MIME_TYPES.has(mimeType) &&
isBufferLike(file)
) {
try {
const inputBuffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
// rotate() applies EXIF orientation before metadata is stripped
return await sharp(inputBuffer).rotate().toBuffer();
} catch (error) {
throw new FileStorageException(
`Failed to sanitize image metadata: ${error instanceof Error ? error.message : String(error)}`,
FileStorageExceptionCode.SANITIZATION_FAILED,
);
}
}
return file;
};