@@ -12,27 +12,112 @@ if (Meteor.isServer) {
1212
1313export async function isFileValid ( fileObj , mimeTypesAllowed , sizeAllowed , externalCommandLine ) {
1414 let isValid = true ;
15+ // Always validate uploads. The previous migration flag disabled validation and enabled XSS.
16+ try {
17+ // Helper: read up to a limit from a file as UTF-8 text
18+ const readTextHead = ( filePath , limit = parseInt ( process . env . UPLOAD_DANGEROUS_MIME_SCAN_LIMIT || '1048576' ) ) => new Promise ( ( resolve , reject ) => {
19+ try {
20+ const stream = fs . createReadStream ( filePath , { encoding : 'utf8' , highWaterMark : 64 * 1024 } ) ;
21+ let data = '' ;
22+ let exceeded = false ;
23+ stream . on ( 'data' , chunk => {
24+ data += chunk ;
25+ if ( data . length >= limit ) {
26+ exceeded = true ;
27+ stream . destroy ( ) ;
28+ }
29+ } ) ;
30+ stream . on ( 'error' , err => reject ( err ) ) ;
31+ stream . on ( 'close' , ( ) => {
32+ if ( exceeded ) {
33+ // If file exceeds scan limit, treat as unsafe
34+ resolve ( { text : data . slice ( 0 , limit ) , complete : false } ) ;
35+ } else {
36+ resolve ( { text : data , complete : true } ) ;
37+ }
38+ } ) ;
39+ } catch ( e ) {
40+ reject ( e ) ;
41+ }
42+ } ) ;
43+
44+ // Helper: quick content safety checks for HTML/SVG/XML
45+ const containsJsOrXmlBombs = ( text ) => {
46+ if ( ! text ) return false ;
47+ const t = text . toLowerCase ( ) ;
48+ // JavaScript execution vectors
49+ const patterns = [
50+ / < s c r i p t \b / i,
51+ / o n [ a - z \- ] { 1 , 20 } \s * = \s * [ ' " ] / i, // event handlers
52+ / j a v a s c r i p t \s * : / i,
53+ / < i f r a m e \b / i,
54+ / < o b j e c t \b / i,
55+ / < e m b e d \b / i,
56+ / < m e t a \s + h t t p - e q u i v \s * = \s * [ ' " ] ? r e f r e s h / i,
57+ / < f o r e i g n o b j e c t \b / i,
58+ / s t y l e \s * = \s * [ ' " ] [ ^ ' " ] * u r l \( \s * j a v a s c r i p t \s * : / i,
59+ ] ;
60+ if ( patterns . some ( ( re ) => re . test ( text ) ) ) return true ;
61+ // XML entity expansion / DTD based bombs
62+ if ( t . includes ( '<!doctype' ) || t . includes ( '<!entity' ) || t . includes ( '<?xml-stylesheet' ) ) return true ;
63+ return false ;
64+ } ;
65+
66+ const checkDangerousMimeAllowance = async ( mime , filePath , fileSize ) => {
67+ // Allow only if content is scanned and clean
68+ const { text, complete } = await readTextHead ( filePath ) ;
69+ if ( ! complete ) {
70+ // Too large to confidently scan
71+ return false ;
72+ }
73+ // For JS MIME, only allow empty files
74+ if ( mime === 'application/javascript' || mime === 'text/javascript' ) {
75+ return ( text . trim ( ) . length === 0 ) ;
76+ }
77+ return ! containsJsOrXmlBombs ( text ) ;
78+ } ;
1579
16- /*
17- if (Meteor.settings.public.ostrioFilesMigrationInProgress !== "true") {
18- if (mimeTypesAllowed.length) {
19- const mimeTypeResult = await FileType.fromFile(fileObj.path) ;
80+ // Detect MIME type from file content when possible
81+ const mimeTypeResult = await FileType . fromFile ( fileObj . path ) . catch ( ( ) => undefined ) ;
82+ const detectedMime = mimeTypeResult ?. mime || ( fileObj . type || '' ) . toLowerCase ( ) ;
83+ const baseMimeType = detectedMime . split ( '/' , 1 ) [ 0 ] || '' ;
2084
21- const mimeType = (mimeTypeResult ? mimeTypeResult.mime : fileObj.type);
22- const baseMimeType = mimeType.split('/', 1)[0];
85+ // Hard deny-list for obviously dangerous types which can be allowed if content is safe
86+ const dangerousMimes = new Set ( [
87+ 'text/html' ,
88+ 'application/xhtml+xml' ,
89+ 'image/svg+xml' ,
90+ 'text/xml' ,
91+ 'application/xml' ,
92+ 'application/javascript' ,
93+ 'text/javascript'
94+ ] ) ;
95+ if ( dangerousMimes . has ( detectedMime ) ) {
96+ const allowedByContentScan = await checkDangerousMimeAllowance ( detectedMime , fileObj . path , fileObj . size || 0 ) ;
97+ if ( ! allowedByContentScan ) {
98+ console . log ( "Validation of uploaded file failed (dangerous MIME content): file " + fileObj . path + " - mimetype " + detectedMime ) ;
99+ return false ;
100+ }
101+ }
23102
24- isValid = mimeTypesAllowed.includes(mimeType) || mimeTypesAllowed.includes(baseMimeType + '/*') || mimeTypesAllowed.includes('*');
103+ // Optional allow-list: if provided, enforce it using exact or base type match
104+ if ( Array . isArray ( mimeTypesAllowed ) && mimeTypesAllowed . length ) {
105+ isValid = mimeTypesAllowed . includes ( detectedMime )
106+ || ( baseMimeType && mimeTypesAllowed . includes ( baseMimeType + '/*' ) )
107+ || mimeTypesAllowed . includes ( '*' ) ;
25108
26109 if ( ! isValid ) {
27- console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + mimeType );
110+ console . log ( "Validation of uploaded file failed: file " + fileObj . path + " - mimetype " + detectedMime ) ;
28111 }
29112 }
30113
114+ // Size check
31115 if ( isValid && sizeAllowed && fileObj . size > sizeAllowed ) {
32116 console . log ( "Validation of uploaded file failed: file " + fileObj . path + " - size " + fileObj . size ) ;
33117 isValid = false ;
34118 }
35119
120+ // External scanner (e.g., antivirus) – expected to delete/quarantine bad files
36121 if ( isValid && externalCommandLine ) {
37122 await asyncExec ( externalCommandLine . replace ( "{file}" , '"' + fileObj . path + '"' ) ) ;
38123 isValid = fs . existsSync ( fileObj . path ) ;
@@ -45,8 +130,9 @@ export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, extern
45130 if ( isValid ) {
46131 console . debug ( "Validation of uploaded file successful: file " + fileObj . path ) ;
47132 }
133+ } catch ( e ) {
134+ console . error ( 'Error during file validation:' , e ) ;
135+ isValid = false ;
48136 }
49- */
50-
51137 return isValid ;
52138}
0 commit comments