Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
314f72abf0 | ||
|
|
d8b50851c9 | ||
|
|
b6ce89794e | ||
|
|
e8ca9d0c85 | ||
|
|
ec6a52c370 | ||
|
|
38fdc76388 |
+5
-1
@@ -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,
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}` : ''}`;
|
||||
|
||||
+1
-1
@@ -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}` : ''}`;
|
||||
|
||||
+1
-1
@@ -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}` : ''}`;
|
||||
|
||||
+1
-1
@@ -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}` : ''}`;
|
||||
|
||||
+1
-1
@@ -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}` : ''}`;
|
||||
|
||||
+190
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user