Skip to content

Commit e47d946

Browse files
Add MIME type and extension validation for file uploads (#5596)
* Add MIME type and extension validation for file uploads - Updated jest.config.js to include the 'src' directory in test roots. - Introduced a new validator for checking MIME type and file extension matches, addressing security vulnerabilities. - Implemented validation in multiple server controllers and utilities to prevent MIME type spoofing attacks. - Added comprehensive tests for the new validation logic in validator.test.ts. * Update packages/components/src/validator.ts isUnsafeFilePath function checks for various security risks, including absolute paths, null bytes, and control characters, not just path traversal. A more general error message would be more appropriate to cover all cases. Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * address review comments * remove semicolon * fix validator unit test: missing update in test to match error message changes in source * support image and other media types in mime to ext conversion; address other review comments * address gemini review comments --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 67b7ec1 commit e47d946

File tree

12 files changed

+401
-4
lines changed

12 files changed

+401
-4
lines changed

packages/components/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
preset: 'ts-jest',
33
testEnvironment: 'node',
4-
roots: ['<rootDir>/nodes'],
4+
roots: ['<rootDir>/nodes', '<rootDir>/src'],
55
transform: {
66
'^.+\\.tsx?$': 'ts-jest'
77
},

packages/components/src/handler.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1+
import { OTLPTraceExporter as ProtoOTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
12
import { getPhoenixTracer } from './handler'
23

34
jest.mock('@opentelemetry/exporter-trace-otlp-proto', () => {
45
return {
5-
ProtoOTLPTraceExporter: jest.fn().mockImplementation((args) => {
6+
OTLPTraceExporter: jest.fn().mockImplementation((args) => {
67
return { args }
78
})
89
}
910
})
1011

11-
import { OTLPTraceExporter as ProtoOTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
12-
1312
describe('URL Handling For Phoenix Tracer', () => {
1413
const apiKey = 'test-api-key'
1514
const projectName = 'test-project-name'

packages/components/src/utils.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,18 @@ export const mapMimeTypeToExt = (mimeType: string) => {
11301130
case 'application/jsonl':
11311131
case 'text/jsonl':
11321132
return 'jsonl'
1133+
// YAML types
1134+
case 'application/vnd.yaml':
1135+
case 'application/x-yaml':
1136+
case 'text/vnd.yaml':
1137+
case 'text/x-yaml':
1138+
case 'text/yaml':
1139+
return 'yaml'
1140+
// SQL types
1141+
case 'application/sql':
1142+
case 'text/x-sql':
1143+
return 'sql'
1144+
// Document types
11331145
case 'application/msword':
11341146
return 'doc'
11351147
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
@@ -1142,6 +1154,59 @@ export const mapMimeTypeToExt = (mimeType: string) => {
11421154
return 'ppt'
11431155
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
11441156
return 'pptx'
1157+
case 'application/rtf':
1158+
return 'rtf'
1159+
// Image types
1160+
case 'image/jpeg':
1161+
case 'image/jpg':
1162+
return 'jpg'
1163+
case 'image/png':
1164+
return 'png'
1165+
case 'image/gif':
1166+
return 'gif'
1167+
case 'image/webp':
1168+
return 'webp'
1169+
case 'image/svg+xml':
1170+
return 'svg'
1171+
case 'image/bmp':
1172+
return 'bmp'
1173+
case 'image/tiff':
1174+
case 'image/tif':
1175+
return 'tiff'
1176+
case 'image/x-icon':
1177+
case 'image/vnd.microsoft.icon':
1178+
return 'ico'
1179+
case 'image/avif':
1180+
return 'avif'
1181+
// Audio types
1182+
case 'audio/webm':
1183+
return 'webm'
1184+
case 'audio/mp4':
1185+
case 'audio/x-m4a':
1186+
return 'm4a'
1187+
case 'audio/mpeg':
1188+
case 'audio/mp3':
1189+
return 'mp3'
1190+
case 'audio/ogg':
1191+
case 'audio/oga':
1192+
return 'ogg'
1193+
case 'audio/wav':
1194+
case 'audio/wave':
1195+
case 'audio/x-wav':
1196+
return 'wav'
1197+
case 'audio/aac':
1198+
return 'aac'
1199+
case 'audio/flac':
1200+
return 'flac'
1201+
// Video types
1202+
case 'video/mp4':
1203+
return 'mp4'
1204+
case 'video/webm':
1205+
return 'webm'
1206+
case 'video/quicktime':
1207+
return 'mov'
1208+
case 'video/x-msvideo':
1209+
return 'avi'
11451210
default:
11461211
return ''
11471212
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { validateMimeTypeAndExtensionMatch } from './validator'
2+
3+
describe('validateMimeTypeAndExtensionMatch', () => {
4+
describe('valid cases', () => {
5+
it.each([
6+
['document.txt', 'text/plain'],
7+
['page.html', 'text/html'],
8+
['data.json', 'application/json'],
9+
['document.pdf', 'application/pdf'],
10+
['script.js', 'text/javascript'],
11+
['script.js', 'application/javascript'],
12+
['readme.md', 'text/markdown'],
13+
['readme.md', 'text/x-markdown'],
14+
['DOCUMENT.TXT', 'text/plain'],
15+
['Document.TxT', 'text/plain'],
16+
['my.document.txt', 'text/plain'],
17+
// Image types
18+
['photo.jpg', 'image/jpeg'],
19+
['photo.jpeg', 'image/jpeg'], // .jpeg should be normalized to .jpg
20+
['PHOTO.JPEG', 'image/jpeg'], // Case insensitive and normalization
21+
['image.png', 'image/png'],
22+
['animation.gif', 'image/gif'],
23+
['picture.webp', 'image/webp'],
24+
['icon.svg', 'image/svg+xml'],
25+
['IMAGE.PNG', 'image/png'],
26+
// Audio types
27+
['audio.webm', 'audio/webm'],
28+
['sound.m4a', 'audio/mp4'],
29+
['sound.m4a', 'audio/x-m4a'],
30+
['music.mp3', 'audio/mpeg'],
31+
['music.mp3', 'audio/mp3'],
32+
['audio.ogg', 'audio/ogg'],
33+
['audio.oga', 'audio/ogg'], // .oga should normalize to ogg
34+
['audio.oga', 'audio/oga'],
35+
['sound.wav', 'audio/wav'],
36+
['sound.wav', 'audio/wave'],
37+
['sound.wav', 'audio/x-wav'],
38+
['audio.aac', 'audio/aac'],
39+
['audio.flac', 'audio/flac'],
40+
// Video types
41+
['video.mp4', 'video/mp4'],
42+
['video.webm', 'video/webm'],
43+
['movie.mov', 'video/quicktime'],
44+
['clip.avi', 'video/x-msvideo'],
45+
// YAML types
46+
['config.yaml', 'application/vnd.yaml'],
47+
['config.yaml', 'application/x-yaml'],
48+
['config.yaml', 'text/vnd.yaml'],
49+
['config.yaml', 'text/x-yaml'],
50+
['config.yaml', 'text/yaml'],
51+
// SQL types
52+
['query.sql', 'application/sql'],
53+
['query.sql', 'text/x-sql'],
54+
// Document types
55+
['document.rtf', 'application/rtf'],
56+
// Additional image types
57+
['image.tiff', 'image/tiff'],
58+
['image.tif', 'image/tiff'], // .tif should normalize to tiff
59+
['image.tif', 'image/tif'],
60+
['icon.ico', 'image/x-icon'],
61+
['icon.ico', 'image/vnd.microsoft.icon'],
62+
['photo.avif', 'image/avif']
63+
])('should pass validation for matching MIME type and extension - %s with %s', (filename, mimetype) => {
64+
expect(() => {
65+
validateMimeTypeAndExtensionMatch(filename, mimetype)
66+
}).not.toThrow()
67+
})
68+
})
69+
70+
describe('invalid filename', () => {
71+
it.each([
72+
['empty filename', ''],
73+
['null filename', null],
74+
['undefined filename', undefined],
75+
['non-string filename (number)', 123],
76+
['object filename', {}]
77+
])('should throw error for %s', (_description, filename) => {
78+
expect(() => {
79+
validateMimeTypeAndExtensionMatch(filename as unknown as string, 'text/plain')
80+
}).toThrow('Invalid filename: filename is required and must be a string')
81+
})
82+
})
83+
84+
describe('invalid MIME type', () => {
85+
it.each([
86+
['empty MIME type', ''],
87+
['null MIME type', null],
88+
['undefined MIME type', undefined],
89+
['non-string MIME type (number)', 123]
90+
])('should throw error for %s', (_description, mimetype) => {
91+
expect(() => {
92+
validateMimeTypeAndExtensionMatch('file.txt', mimetype as unknown as string)
93+
}).toThrow('Invalid MIME type: MIME type is required and must be a string')
94+
})
95+
})
96+
97+
describe('path traversal detection', () => {
98+
it.each([
99+
['filename with ..', '../file.txt'],
100+
['filename with .. in middle', 'path/../file.txt'],
101+
['filename with multle levels of ..', '../../../etc/passwd.txt'],
102+
['filename with ..\\..\\..', '..\\..\\..\\windows\\system32\\file.txt'],
103+
['filename with ....//....//', '....//....//etc/passwd.txt'],
104+
['filename starting with /', '/etc/passwd.txt'],
105+
['Windows absolute path', 'C:\\file.txt'],
106+
['URL encoded path traversal', '%2e%2e/file.txt'],
107+
['URL encoded path traversal multiple levels', '%2e%2e%2f%2e%2e%2f%2e%2e%2ffile.txt'],
108+
['null byte', 'file\0.txt']
109+
])('should throw error for %s', (_description, filename) => {
110+
expect(() => {
111+
validateMimeTypeAndExtensionMatch(filename, 'text/plain')
112+
}).toThrow(`Invalid filename: unsafe characters or path traversal attempt detected in filename "${filename}"`)
113+
})
114+
})
115+
116+
describe('files without extensions', () => {
117+
it.each([
118+
['filename without extension', 'file'],
119+
['filename ending with dot', 'file.']
120+
])('should throw error for %s', (_description, filename) => {
121+
expect(() => {
122+
validateMimeTypeAndExtensionMatch(filename, 'text/plain')
123+
}).toThrow('File type not allowed: files must have a valid file extension')
124+
})
125+
})
126+
127+
describe('unsupported MIME types', () => {
128+
it.each([
129+
['application/octet-stream', 'file.txt'],
130+
['invalid-mime-type', 'file.txt'],
131+
['application/x-msdownload', 'malware.exe'],
132+
['application/x-executable', 'script.exe'],
133+
['application/x-msdownload', 'program.EXE'],
134+
['application/octet-stream', 'script.js']
135+
])('should throw error for unsupported MIME type %s with %s', (mimetype, filename) => {
136+
expect(() => {
137+
validateMimeTypeAndExtensionMatch(filename, mimetype)
138+
}).toThrow(`MIME type "${mimetype}" is not supported or does not have a valid file extension mapping`)
139+
})
140+
})
141+
142+
describe('MIME type and extension mismatches', () => {
143+
it.each([
144+
// [filename, mimetype, actualExt, expectedExt]
145+
['file.txt', 'application/json', 'txt', 'json'],
146+
['script.js', 'application/pdf', 'js', 'pdf'],
147+
['page.html', 'text/plain', 'html', 'txt'],
148+
['document.pdf', 'application/json', 'pdf', 'json'],
149+
['data.json', 'text/plain', 'json', 'txt'],
150+
['malware.exe', 'text/plain', 'exe', 'txt'],
151+
['script.js', 'application/json', 'js', 'json'],
152+
// Image/audio mismatches
153+
['photo.jpg', 'image/png', 'jpg', 'png'],
154+
['image.png', 'image/jpeg', 'png', 'jpg'],
155+
['audio.mp3', 'audio/wav', 'mp3', 'wav'],
156+
['sound.wav', 'audio/mpeg', 'wav', 'mp3'],
157+
// New type mismatches
158+
['config.yaml', 'application/json', 'yaml', 'json'],
159+
['query.sql', 'text/plain', 'sql', 'txt'],
160+
['document.rtf', 'application/pdf', 'rtf', 'pdf'],
161+
['video.mp4', 'video/webm', 'mp4', 'webm'],
162+
['image.tiff', 'image/png', 'tiff', 'png'],
163+
['icon.ico', 'image/png', 'ico', 'png']
164+
])('should throw error when extension does not match MIME type - %s with %s', (filename, mimetype, actualExt, expectedExt) => {
165+
expect(() => {
166+
validateMimeTypeAndExtensionMatch(filename, mimetype)
167+
}).toThrow(
168+
`MIME type mismatch: file extension "${actualExt}" does not match declared MIME type "${mimetype}". Expected: ${expectedExt}`
169+
)
170+
})
171+
})
172+
})

packages/components/src/validator.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { mapMimeTypeToExt } from './utils'
2+
13
/**
24
* Validates if a string is a valid UUID v4
35
* @param {string} uuid The string to validate
@@ -69,3 +71,79 @@ export const isUnsafeFilePath = (filePath: string): boolean => {
6971

7072
return dangerousPatterns.some((pattern) => pattern.test(filePath))
7173
}
74+
75+
/**
76+
* Validates filename format and security
77+
* @param {string} filename The filename to validate
78+
* @returns {void} Throws an error if validation fails
79+
*/
80+
const validateFilename = (filename: string): void => {
81+
if (!filename || typeof filename !== 'string') {
82+
throw new Error('Invalid filename: filename is required and must be a string')
83+
}
84+
if (isUnsafeFilePath(filename)) {
85+
throw new Error(`Invalid filename: unsafe characters or path traversal attempt detected in filename "${filename}"`)
86+
}
87+
}
88+
89+
/**
90+
* Extracts and normalizes file extension from filename
91+
* @param {string} filename The filename
92+
* @returns {string} The normalized extension (lowercase, without dot) or empty string
93+
*/
94+
const extractFileExtension = (filename: string): string => {
95+
const filenameParts = filename.split('.')
96+
if (filenameParts.length <= 1) {
97+
return ''
98+
}
99+
let ext = filenameParts.pop()!.toLowerCase()
100+
// Normalize common extension variations to match MIME type mappings
101+
const extensionNormalizationMap: { [key: string]: string } = {
102+
jpeg: 'jpg', // image/jpeg and image/jpg both map to 'jpg'
103+
tif: 'tiff', // image/tiff and image/tif both map to 'tiff'
104+
oga: 'ogg' // audio/ogg and audio/oga both map to 'ogg'
105+
}
106+
ext = extensionNormalizationMap[ext] ?? ext
107+
return ext
108+
}
109+
110+
/**
111+
* Validates that file extension matches the declared MIME type
112+
*
113+
* This function addresses CVE-2025-61687 by preventing MIME type spoofing attacks.
114+
* It ensures that the file extension matches the declared MIME type, preventing
115+
* attackers from uploading malicious files (e.g., .js file with text/plain MIME type).
116+
*
117+
* @param {string} filename The original filename
118+
* @param {string} mimetype The declared MIME type
119+
* @returns {void} Throws an error if validation fails
120+
*/
121+
export const validateMimeTypeAndExtensionMatch = (filename: string, mimetype: string): void => {
122+
validateFilename(filename)
123+
124+
if (!mimetype || typeof mimetype !== 'string') {
125+
throw new Error('Invalid MIME type: MIME type is required and must be a string')
126+
}
127+
128+
const normalizedExt = extractFileExtension(filename)
129+
130+
if (!normalizedExt) {
131+
// Files without extensions are rejected for security
132+
throw new Error('File type not allowed: files must have a valid file extension')
133+
}
134+
135+
// Get the expected extension from mapMimeTypeToExt (returns extension without dot)
136+
const expectedExt = mapMimeTypeToExt(mimetype)
137+
138+
if (!expectedExt) {
139+
// If mapMimeTypeToExt doesn't recognize the MIME type, it's not supported
140+
throw new Error(`MIME type "${mimetype}" is not supported or does not have a valid file extension mapping`)
141+
}
142+
143+
// Ensure the file extension matches the expected extension for the MIME type
144+
if (normalizedExt !== expectedExt) {
145+
throw new Error(
146+
`MIME type mismatch: file extension "${normalizedExt}" does not match declared MIME type "${mimetype}". Expected: ${expectedExt}`
147+
)
148+
}
149+
}

packages/server/src/controllers/openai-assistants-vector-store/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
22
import { StatusCodes } from 'http-status-codes'
33
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
44
import openAIAssistantVectorStoreService from '../../services/openai-assistants-vector-store'
5+
import { validateFileMimeTypeAndExtensionMatch } from '../../utils/fileValidation'
56

67
const getAssistantVectorStore = async (req: Request, res: Response, next: NextFunction) => {
78
try {
@@ -142,6 +143,10 @@ const uploadFilesToAssistantVectorStore = async (req: Request, res: Response, ne
142143
for (const file of files) {
143144
// Address file name with special characters: https://github.com/expressjs/multer/issues/1104
144145
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8')
146+
147+
// Validate file extension, MIME type, and content to prevent security vulnerabilities
148+
validateFileMimeTypeAndExtensionMatch(file.originalname, file.mimetype)
149+
145150
uploadFiles.push({
146151
filePath: file.path ?? file.key,
147152
fileName: file.originalname

0 commit comments

Comments
 (0)