fileValidation.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { Meteor } from 'meteor/meteor';
  2. import { exec } from 'node:child_process';
  3. import { promisify } from 'node:util';
  4. import fs from 'fs';
  5. import FileType from 'file-type';
  6. let asyncExec;
  7. if (Meteor.isServer) {
  8. asyncExec = promisify(exec);
  9. }
  10. export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, externalCommandLine) {
  11. let isValid = true;
  12. // Always validate uploads. The previous migration flag disabled validation and enabled XSS.
  13. try {
  14. // Helper: read up to a limit from a file as UTF-8 text
  15. const readTextHead = (filePath, limit = parseInt(process.env.UPLOAD_DANGEROUS_MIME_SCAN_LIMIT || '1048576')) => new Promise((resolve, reject) => {
  16. try {
  17. const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
  18. let data = '';
  19. let exceeded = false;
  20. stream.on('data', chunk => {
  21. data += chunk;
  22. if (data.length >= limit) {
  23. exceeded = true;
  24. stream.destroy();
  25. }
  26. });
  27. stream.on('error', err => reject(err));
  28. stream.on('close', () => {
  29. if (exceeded) {
  30. // If file exceeds scan limit, treat as unsafe
  31. resolve({ text: data.slice(0, limit), complete: false });
  32. } else {
  33. resolve({ text: data, complete: true });
  34. }
  35. });
  36. } catch (e) {
  37. reject(e);
  38. }
  39. });
  40. // Helper: quick content safety checks for HTML/SVG/XML
  41. const containsJsOrXmlBombs = (text) => {
  42. if (!text) return false;
  43. const t = text.toLowerCase();
  44. // JavaScript execution vectors
  45. const patterns = [
  46. /<script\b/i,
  47. /on[a-z\-]{1,20}\s*=\s*['"]/i, // event handlers
  48. /javascript\s*:/i,
  49. /<iframe\b/i,
  50. /<object\b/i,
  51. /<embed\b/i,
  52. /<meta\s+http-equiv\s*=\s*['"]?refresh/i,
  53. /<foreignobject\b/i,
  54. /style\s*=\s*['"][^'"]*url\(\s*javascript\s*:/i,
  55. ];
  56. if (patterns.some((re) => re.test(text))) return true;
  57. // XML entity expansion / DTD based bombs
  58. if (t.includes('<!doctype') || t.includes('<!entity') || t.includes('<?xml-stylesheet')) return true;
  59. return false;
  60. };
  61. const checkDangerousMimeAllowance = async (mime, filePath, fileSize) => {
  62. // Allow only if content is scanned and clean
  63. const { text, complete } = await readTextHead(filePath);
  64. if (!complete) {
  65. // Too large to confidently scan
  66. return false;
  67. }
  68. // For JS MIME, only allow empty files
  69. if (mime === 'application/javascript' || mime === 'text/javascript') {
  70. return (text.trim().length === 0);
  71. }
  72. return !containsJsOrXmlBombs(text);
  73. };
  74. // Detect MIME type from file content when possible
  75. const mimeTypeResult = await FileType.fromFile(fileObj.path).catch(() => undefined);
  76. const detectedMime = mimeTypeResult?.mime || (fileObj.type || '').toLowerCase();
  77. const baseMimeType = detectedMime.split('/', 1)[0] || '';
  78. // Hard deny-list for obviously dangerous types which can be allowed if content is safe
  79. const dangerousMimes = new Set([
  80. 'text/html',
  81. 'application/xhtml+xml',
  82. 'image/svg+xml',
  83. 'text/xml',
  84. 'application/xml',
  85. 'application/javascript',
  86. 'text/javascript'
  87. ]);
  88. if (dangerousMimes.has(detectedMime)) {
  89. const allowedByContentScan = await checkDangerousMimeAllowance(detectedMime, fileObj.path, fileObj.size || 0);
  90. if (!allowedByContentScan) {
  91. console.log("Validation of uploaded file failed (dangerous MIME content): file " + fileObj.path + " - mimetype " + detectedMime);
  92. return false;
  93. }
  94. }
  95. // Optional allow-list: if provided, enforce it using exact or base type match
  96. if (Array.isArray(mimeTypesAllowed) && mimeTypesAllowed.length) {
  97. isValid = mimeTypesAllowed.includes(detectedMime)
  98. || (baseMimeType && mimeTypesAllowed.includes(baseMimeType + '/*'))
  99. || mimeTypesAllowed.includes('*');
  100. if (!isValid) {
  101. console.log("Validation of uploaded file failed: file " + fileObj.path + " - mimetype " + detectedMime);
  102. }
  103. }
  104. // Size check
  105. if (isValid && sizeAllowed && fileObj.size > sizeAllowed) {
  106. console.log("Validation of uploaded file failed: file " + fileObj.path + " - size " + fileObj.size);
  107. isValid = false;
  108. }
  109. // External scanner (e.g., antivirus) – expected to delete/quarantine bad files
  110. if (isValid && externalCommandLine) {
  111. await asyncExec(externalCommandLine.replace("{file}", '"' + fileObj.path + '"'));
  112. isValid = fs.existsSync(fileObj.path);
  113. if (!isValid) {
  114. console.log("Validation of uploaded file failed: file " + fileObj.path + " has been deleted externally");
  115. }
  116. }
  117. if (isValid) {
  118. console.debug("Validation of uploaded file successful: file " + fileObj.path);
  119. }
  120. } catch (e) {
  121. console.error('Error during file validation:', e);
  122. isValid = false;
  123. }
  124. return isValid;
  125. }