diff --git a/packages/components/jest.config.js b/packages/components/jest.config.js index deffa4d4b11..d4f1cfbf294 100644 --- a/packages/components/jest.config.js +++ b/packages/components/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/nodes'], + roots: ['/nodes', '/src'], transform: { '^.+\\.tsx?$': 'ts-jest' }, diff --git a/packages/components/src/handler.test.ts b/packages/components/src/handler.test.ts index 333b2cba818..394ce7092e3 100644 --- a/packages/components/src/handler.test.ts +++ b/packages/components/src/handler.test.ts @@ -1,15 +1,14 @@ +import { OTLPTraceExporter as ProtoOTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' import { getPhoenixTracer } from './handler' jest.mock('@opentelemetry/exporter-trace-otlp-proto', () => { return { - ProtoOTLPTraceExporter: jest.fn().mockImplementation((args) => { + OTLPTraceExporter: jest.fn().mockImplementation((args) => { return { args } }) } }) -import { OTLPTraceExporter as ProtoOTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' - describe('URL Handling For Phoenix Tracer', () => { const apiKey = 'test-api-key' const projectName = 'test-project-name' diff --git a/packages/components/src/validator.test.ts b/packages/components/src/validator.test.ts new file mode 100644 index 00000000000..cae173484f9 --- /dev/null +++ b/packages/components/src/validator.test.ts @@ -0,0 +1,114 @@ +import { validateMimeTypeAndExtensionMatch } from './validator' + +describe('validateMimeTypeAndExtensionMatch', () => { + describe('valid cases', () => { + it.each([ + ['document.txt', 'text/plain'], + ['page.html', 'text/html'], + ['data.json', 'application/json'], + ['document.pdf', 'application/pdf'], + ['script.js', 'text/javascript'], + ['script.js', 'application/javascript'], + ['readme.md', 'text/markdown'], + ['readme.md', 'text/x-markdown'], + ['DOCUMENT.TXT', 'text/plain'], + ['Document.TxT', 'text/plain'], + ['my.document.txt', 'text/plain'] + ])('should pass validation for matching MIME type and extension - %s with %s', (filename, mimetype) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename, mimetype) + }).not.toThrow() + }) + }) + + describe('invalid filename', () => { + it.each([ + ['empty filename', ''], + ['null filename', null], + ['undefined filename', undefined], + ['non-string filename (number)', 123], + ['object filename', {}] + ])('should throw error for %s', (_description, filename) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename as any, 'text/plain') + }).toThrow('Invalid filename: filename is required and must be a string') + }) + }) + + describe('invalid MIME type', () => { + it.each([ + ['empty MIME type', ''], + ['null MIME type', null], + ['undefined MIME type', undefined], + ['non-string MIME type (number)', 123] + ])('should throw error for %s', (_description, mimetype) => { + expect(() => { + validateMimeTypeAndExtensionMatch('file.txt', mimetype as any) + }).toThrow('Invalid MIME type: MIME type is required and must be a string') + }) + }) + + describe('path traversal detection', () => { + it.each([ + ['filename with ..', '../file.txt'], + ['filename with .. in middle', 'path/../file.txt'], + ['filename with multle levels of ..', '../../../etc/passwd.txt'], + ['filename with ..\\..\\..', '..\\..\\..\\windows\\system32\\file.txt'], + ['filename with ....//....//', '....//....//etc/passwd.txt'], + ['filename starting with /', '/etc/passwd.txt'], + ['Windows absolute path', 'C:\\file.txt'], + ['URL encoded path traversal', '%2e%2e/file.txt'], + ['URL encoded path traversal multiple levels', '%2e%2e%2f%2e%2e%2f%2e%2e%2ffile.txt'], + ['null byte', 'file\0.txt'] + ])('should throw error for %s', (_description, filename) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename, 'text/plain') + }).toThrow(`Invalid filename: unsafe characters or path traversal attempt detected in filename "${filename}"`) + }) + }) + + describe('files without extensions', () => { + it.each([ + ['filename without extension', 'file'], + ['filename ending with dot', 'file.'] + ])('should throw error for %s', (_description, filename) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename, 'text/plain') + }).toThrow('File type not allowed: files must have a valid file extension') + }) + }) + + describe('unsupported MIME types', () => { + it.each([ + ['application/octet-stream', 'file.txt'], + ['invalid-mime-type', 'file.txt'], + ['application/x-msdownload', 'malware.exe'], + ['application/x-executable', 'script.exe'], + ['application/x-msdownload', 'program.EXE'], + ['application/octet-stream', 'script.js'] + ])('should throw error for unsupported MIME type %s with %s', (mimetype, filename) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename, mimetype) + }).toThrow(`MIME type "${mimetype}" is not supported or does not have a valid file extension mapping`) + }) + }) + + describe('MIME type and extension mismatches', () => { + it.each([ + // [filename, mimetype, actualExt, expectedExt] + ['file.txt', 'application/json', 'txt', 'json'], + ['script.js', 'application/pdf', 'js', 'pdf'], + ['page.html', 'text/plain', 'html', 'txt'], + ['document.pdf', 'application/json', 'pdf', 'json'], + ['data.json', 'text/plain', 'json', 'txt'], + ['malware.exe', 'text/plain', 'exe', 'txt'], + ['script.js', 'application/json', 'js', 'json'] + ])('should throw error when extension does not match MIME type - %s with %s', (filename, mimetype, actualExt, expectedExt) => { + expect(() => { + validateMimeTypeAndExtensionMatch(filename, mimetype) + }).toThrow( + `MIME type mismatch: file extension "${actualExt}" does not match declared MIME type "${mimetype}". Expected: ${expectedExt}` + ) + }) + }) +}) diff --git a/packages/components/src/validator.ts b/packages/components/src/validator.ts index 5a72144f0cd..67792e0e0c8 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -1,3 +1,5 @@ +import { mapMimeTypeToExt } from './utils' + /** * Validates if a string is a valid UUID v4 * @param {string} uuid The string to validate @@ -69,3 +71,68 @@ export const isUnsafeFilePath = (filePath: string): boolean => { return dangerousPatterns.some((pattern) => pattern.test(filePath)) } + +/** + * Validates filename format and security + * @param {string} filename The filename to validate + * @returns {void} Throws an error if validation fails + */ +const validateFilename = (filename: string): void => { + if (!filename || typeof filename !== 'string') { + throw new Error('Invalid filename: filename is required and must be a string') + } + if (isUnsafeFilePath(filename)) { + throw new Error(`Invalid filename: unsafe characters or path traversal attempt detected in filename "${filename}"`) + } +} + +/** + * Extracts and normalizes file extension from filename + * @param {string} filename The filename + * @returns {string} The normalized extension (lowercase, without dot) or empty string + */ +const extractFileExtension = (filename: string): string => { + const filenameParts = filename.split('.') + return filenameParts.length > 1 ? filenameParts.pop()!.toLowerCase() : '' +} + +/** + * Validates that file extension matches the declared MIME type + * + * This function addresses CVE-2025-61687 by preventing MIME type spoofing attacks. + * It ensures that the file extension matches the declared MIME type, preventing + * attackers from uploading malicious files (e.g., .js file with text/plain MIME type). + * + * @param {string} filename The original filename + * @param {string} mimetype The declared MIME type + * @returns {void} Throws an error if validation fails + */ +export const validateMimeTypeAndExtensionMatch = (filename: string, mimetype: string): void => { + validateFilename(filename) + + if (!mimetype || typeof mimetype !== 'string') { + throw new Error('Invalid MIME type: MIME type is required and must be a string') + } + + const normalizedExt = extractFileExtension(filename) + + if (!normalizedExt) { + // Files without extensions are rejected for security + throw new Error('File type not allowed: files must have a valid file extension') + } + + // Get the expected extension from mapMimeTypeToExt (returns extension without dot) + const expectedExt = mapMimeTypeToExt(mimetype) + + if (!expectedExt) { + // If mapMimeTypeToExt doesn't recognize the MIME type, it's not supported + throw new Error(`MIME type "${mimetype}" is not supported or does not have a valid file extension mapping`) + } + + // Ensure the file extension matches the expected extension for the MIME type + if (normalizedExt !== expectedExt) { + throw new Error( + `MIME type mismatch: file extension "${normalizedExt}" does not match declared MIME type "${mimetype}". Expected: ${expectedExt}` + ) + } +} diff --git a/packages/server/src/controllers/openai-assistants-vector-store/index.ts b/packages/server/src/controllers/openai-assistants-vector-store/index.ts index f2216992260..120a1e9f2a5 100644 --- a/packages/server/src/controllers/openai-assistants-vector-store/index.ts +++ b/packages/server/src/controllers/openai-assistants-vector-store/index.ts @@ -1,7 +1,9 @@ import { Request, Response, NextFunction } from 'express' import { StatusCodes } from 'http-status-codes' +import { validateMimeTypeAndExtensionMatch } from 'flowise-components' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import openAIAssistantVectorStoreService from '../../services/openai-assistants-vector-store' +import { getErrorMessage } from '../../errors/utils' const getAssistantVectorStore = async (req: Request, res: Response, next: NextFunction) => { try { @@ -142,6 +144,14 @@ const uploadFilesToAssistantVectorStore = async (req: Request, res: Response, ne for (const file of files) { // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') + + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + uploadFiles.push({ filePath: file.path ?? file.key, fileName: file.originalname diff --git a/packages/server/src/controllers/openai-assistants/index.ts b/packages/server/src/controllers/openai-assistants/index.ts index 0e5f9140024..1fb416fcdea 100644 --- a/packages/server/src/controllers/openai-assistants/index.ts +++ b/packages/server/src/controllers/openai-assistants/index.ts @@ -4,10 +4,11 @@ import openaiAssistantsService from '../../services/openai-assistants' import contentDisposition from 'content-disposition' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { StatusCodes } from 'http-status-codes' -import { streamStorageFile } from 'flowise-components' +import { streamStorageFile, validateMimeTypeAndExtensionMatch } from 'flowise-components' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { ChatFlow } from '../../database/entities/ChatFlow' import { Workspace } from '../../enterprise/database/entities/workspace.entity' +import { getErrorMessage } from '../../errors/utils' // List available assistants const getAllOpenaiAssistants = async (req: Request, res: Response, next: NextFunction) => { @@ -104,6 +105,14 @@ const uploadAssistantFiles = async (req: Request, res: Response, next: NextFunct for (const file of files) { // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') + + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + uploadFiles.push({ filePath: file.path ?? file.key, fileName: file.originalname diff --git a/packages/server/src/services/documentstore/index.ts b/packages/server/src/services/documentstore/index.ts index a24f32f3aa6..7ff89a68bad 100644 --- a/packages/server/src/services/documentstore/index.ts +++ b/packages/server/src/services/documentstore/index.ts @@ -10,7 +10,8 @@ import { mapMimeTypeToInputField, removeFilesFromStorage, removeSpecificFileFromStorage, - removeSpecificFileFromUpload + removeSpecificFileFromUpload, + validateMimeTypeAndExtensionMatch } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { cloneDeep, omit } from 'lodash' @@ -1827,6 +1828,13 @@ const upsertDocStore = async ( // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + try { checkStorage(orgId, subscriptionId, usageCacheManager) const { totalSize } = await addArrayFilesToStorage( diff --git a/packages/server/src/utils/buildChatflow.ts b/packages/server/src/utils/buildChatflow.ts index 68feb9b1f91..252ed319611 100644 --- a/packages/server/src/utils/buildChatflow.ts +++ b/packages/server/src/utils/buildChatflow.ts @@ -18,7 +18,8 @@ import { removeSpecificFileFromUpload, EvaluationRunner, handleEscapeCharacters, - IServerSideEventStreamer + IServerSideEventStreamer, + validateMimeTypeAndExtensionMatch } from 'flowise-components' import { StatusCodes } from 'http-status-codes' import { @@ -354,6 +355,14 @@ export const executeFlow = async ({ const splitDataURI = upload.data.split(',') const bf = Buffer.from(splitDataURI.pop() || '', 'base64') const mime = splitDataURI[0].split(':')[1].split(';')[0] + + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(filename, mime) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + const { totalSize } = await addSingleFileToStorage(mime, bf, filename, orgId, chatflowid, chatId) await updateStorageUsage(orgId, workspaceId, totalSize, usageCacheManager) upload.type = 'stored-file' @@ -418,6 +427,14 @@ export const executeFlow = async ({ const fileBuffer = await getFileFromUpload(file.path ?? file.key) // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') + + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + const { path: storagePath, totalSize } = await addArrayFilesToStorage( file.mimetype, fileBuffer, diff --git a/packages/server/src/utils/createAttachment.ts b/packages/server/src/utils/createAttachment.ts index 10495140dad..b538d109856 100644 --- a/packages/server/src/utils/createAttachment.ts +++ b/packages/server/src/utils/createAttachment.ts @@ -7,8 +7,10 @@ import { mapExtToInputField, mapMimeTypeToInputField, removeSpecificFileFromUpload, + removeSpecificFileFromStorage, isValidUUID, - isPathTraversal + isPathTraversal, + validateMimeTypeAndExtensionMatch } from 'flowise-components' import { getRunningExpressApp } from './getRunningExpressApp' import { getErrorMessage } from '../errors/utils' @@ -141,6 +143,15 @@ export const createFileAttachment = async (req: Request) => { ) } + // Security fix: Verify file extension matches the declared MIME type + // This prevents MIME type spoofing attacks (e.g., uploading .js file with text/plain MIME type) + // This addresses the vulnerability (CVE-2025-61687) + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + await checkStorage(orgId, subscriptionId, appServer.usageCacheManager) const fileBuffer = await getFileFromUpload(file.path ?? file.key) @@ -174,6 +185,9 @@ export const createFileAttachment = async (req: Request) => { await removeSpecificFileFromUpload(file.path ?? file.key) + // Track sanitized filename for cleanup if processing fails + const sanitizedFilename = fileNames.length > 0 ? fileNames[0] : undefined + try { const nodeData = { inputs: { @@ -204,6 +218,23 @@ export const createFileAttachment = async (req: Request) => { content }) } catch (error) { + // Security: Clean up storage if processing failed, which includes invalid file type or content detacted from loader + if (sanitizedFilename) { + console.info(`Clean up storage for ${file.originalname} (${sanitizedFilename}). Reason: ${getErrorMessage(error)}`) + try { + const { totalSize: newTotalSize } = await removeSpecificFileFromStorage( + orgId, + chatflowid, + chatId, + sanitizedFilename + ) + await updateStorageUsage(orgId, workspaceId, newTotalSize, appServer.usageCacheManager) + } catch (cleanupError) { + console.error( + `Failed to cleanup storage for ${file.originalname} (${sanitizedFilename}) - ${getErrorMessage(cleanupError)}` + ) + } + } throw new Error(`Failed createFileAttachment: ${file.originalname} (${file.mimetype} - ${getErrorMessage(error)}`) } } diff --git a/packages/server/src/utils/upsertVector.ts b/packages/server/src/utils/upsertVector.ts index 7e705cf5b38..25ccb1376bd 100644 --- a/packages/server/src/utils/upsertVector.ts +++ b/packages/server/src/utils/upsertVector.ts @@ -7,7 +7,8 @@ import { mapMimeTypeToInputField, mapExtToInputField, getFileFromUpload, - removeSpecificFileFromUpload + removeSpecificFileFromUpload, + validateMimeTypeAndExtensionMatch } from 'flowise-components' import logger from '../utils/logger' import { @@ -70,6 +71,14 @@ export const executeUpsert = async ({ const fileBuffer = await getFileFromUpload(file.path ?? file.key) // Address file name with special characters: https://github.com/expressjs/multer/issues/1104 file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8') + + // Validate file extension, MIME type, and content to prevent security vulnerabilities + try { + validateMimeTypeAndExtensionMatch(file.originalname, file.mimetype) + } catch (error) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, getErrorMessage(error)) + } + const { path: storagePath, totalSize } = await addArrayFilesToStorage( file.mimetype, fileBuffer,