Bläddra i källkod

Security Fix JVN#86586539: Stored XSS.

Thanks to Ryoya Koyama of Mitsui Bussan Secure Directions, Inc and xet7.
Lauri Ojansivu 6 dagar sedan
förälder
incheckning
ee79cab7b2

+ 8 - 11
client/components/activities/activities.js

@@ -1,5 +1,6 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import DOMPurify from 'dompurify';
+import { sanitizeHTML, sanitizeText } from '/client/lib/secureDOMPurify';
 import { TAPi18n } from '/imports/i18n';
 
 const activitiesPerPage = 500;
@@ -216,15 +217,11 @@ BlazeComponent.extendComponent({
             {
               href: source.url,
             },
-            DOMPurify.sanitize(source.system, {
-              ALLOW_UNKNOWN_PROTOCOLS: true,
-            }),
+            sanitizeHTML(source.system),
           ),
         );
       } else {
-        return DOMPurify.sanitize(source.system, {
-          ALLOW_UNKNOWN_PROTOCOLS: true,
-        });
+        return sanitizeHTML(source.system);
       }
     }
     return null;
@@ -248,10 +245,10 @@ BlazeComponent.extendComponent({
               href: `${attachment.link()}?download=true`,
               target: '_blank',
             },
-            DOMPurify.sanitize(attachment.name),
+            sanitizeText(attachment.name),
           ),
         )) ||
-      DOMPurify.sanitize(this.currentData().activity.attachmentName)
+      sanitizeText(this.currentData().activity.attachmentName)
     );
   },
 
@@ -265,7 +262,7 @@ BlazeComponent.extendComponent({
 
 Template.activity.helpers({
   sanitize(value) {
-    return DOMPurify.sanitize(value, { ALLOW_UNKNOWN_PROTOCOLS: true });
+    return sanitizeHTML(value);
   },
 });
 
@@ -336,7 +333,7 @@ function createCardLink(card, board) {
           href: card.originRelativeUrl(),
           class: 'action-card',
         },
-        DOMPurify.sanitize(text, { ALLOW_UNKNOWN_PROTOCOLS: true }),
+        sanitizeHTML(text),
       ),
     )
   );
@@ -353,7 +350,7 @@ function createBoardLink(board, list) {
           href: board.originRelativeUrl(),
           class: 'action-board',
         },
-        DOMPurify.sanitize(text, { ALLOW_UNKNOWN_PROTOCOLS: true }),
+        sanitizeHTML(text),
       ),
     )
   );

+ 4 - 3
client/components/cards/attachments.js

@@ -1,6 +1,7 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ObjectID } from 'bson';
 import DOMPurify from 'dompurify';
+import { sanitizeHTML, sanitizeText } from '/client/lib/secureDOMPurify';
 import uploadProgressManager from '/client/lib/uploadProgressManager';
 
 const filesize = require('filesize');
@@ -269,7 +270,7 @@ Template.attachmentGallery.helpers({
     return ret;
   },
   sanitize(value) {
-    return DOMPurify.sanitize(value);
+    return sanitizeHTML(value);
   },
 });
 
@@ -360,7 +361,7 @@ export function handleFileUpload(card, files) {
     }
 
     const fileId = new ObjectID().toString();
-    let fileName = DOMPurify.sanitize(file.name);
+    let fileName = sanitizeText(file.name);
 
     // If sanitized filename is not same as original filename,
     // it could be XSS that is already fixed with sanitize,
@@ -566,7 +567,7 @@ BlazeComponent.extendComponent({
           const name = this.$('.js-edit-attachment-name')[0]
             .value
             .trim() + this.data().extensionWithDot;
-          if (name === DOMPurify.sanitize(name)) {
+          if (name === sanitizeText(name)) {
             Meteor.call('renameAttachment', this.data()._id, name);
           }
           Popup.back();

+ 3 - 6
client/components/main/editor.js

@@ -325,6 +325,7 @@ BlazeComponent.extendComponent({
 }).register('editor');
 
 import DOMPurify from 'dompurify';
+import { sanitizeHTML } from '/client/lib/secureDOMPurify';
 
 // Additional  safeAttrValue function to allow for other specific protocols
 // See https://github.com/leizongmin/js-xss/issues/52#issuecomment-241354114
@@ -371,9 +372,7 @@ Blaze.Template.registerHelper(
     let content = Blaze.toHTML(view.templateContentBlock);
     const currentBoard = Utils.getCurrentBoard();
     if (!currentBoard)
-      return HTML.Raw(
-        DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
-      );
+      return HTML.Raw(sanitizeHTML(content));
     const knowedUsers = _.union(currentBoard.members.map(member => {
       const u = ReactiveCache.getUser(member.userId);
       if (u) {
@@ -417,9 +416,7 @@ Blaze.Template.registerHelper(
       content = content.replace(fullMention, Blaze.toHTML(link));
     }
 
-    return HTML.Raw(
-      DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
-    );
+    return HTML.Raw(sanitizeHTML(content));
   }),
 );
 

+ 121 - 0
client/lib/secureDOMPurify.js

@@ -0,0 +1,121 @@
+import DOMPurify from 'dompurify';
+
+// Centralized secure DOMPurify configuration to prevent XSS and CSS injection attacks
+export function getSecureDOMPurifyConfig() {
+  return {
+    // Block dangerous elements that can cause XSS and CSS injection
+    FORBID_TAGS: [
+      'svg', 'defs', 'use', 'g', 'symbol', 'marker', 'pattern', 'mask', 'clipPath',
+      'linearGradient', 'radialGradient', 'stop', 'animate', 'animateTransform',
+      'animateMotion', 'set', 'switch', 'foreignObject', 'script', 'style', 'link',
+      'meta', 'iframe', 'object', 'embed', 'applet', 'form', 'input', 'textarea',
+      'select', 'option', 'button', 'label', 'fieldset', 'legend', 'frameset',
+      'frame', 'noframes', 'base', 'basefont', 'isindex', 'dir', 'menu', 'menuitem'
+    ],
+    // Block dangerous attributes that can cause XSS and CSS injection
+    FORBID_ATTR: [
+      'xlink:href', 'href', 'onload', 'onerror', 'onclick', 'onmouseover',
+      'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onselect',
+      'onunload', 'onresize', 'onscroll', 'onkeydown', 'onkeyup', 'onkeypress',
+      'onmousedown', 'onmouseup', 'onmouseover', 'onmouseout', 'onmousemove',
+      'ondblclick', 'oncontextmenu', 'onwheel', 'ontouchstart', 'ontouchend',
+      'ontouchmove', 'ontouchcancel', 'onabort', 'oncanplay', 'oncanplaythrough',
+      'ondurationchange', 'onemptied', 'onended', 'onerror', 'onloadeddata',
+      'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying',
+      'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
+      'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'onbeforeunload',
+      'onhashchange', 'onpagehide', 'onpageshow', 'onpopstate', 'onstorage',
+      'onunload', 'style', 'class', 'id', 'data-*', 'aria-*'
+    ],
+    // Allow only safe image formats and protocols
+    ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
+    // Remove dangerous protocols
+    ALLOW_UNKNOWN_PROTOCOLS: false,
+    // Sanitize URLs to prevent malicious content loading
+    SANITIZE_DOM: true,
+    // Remove dangerous elements completely
+    KEEP_CONTENT: false,
+    // Additional security measures
+    ADD_ATTR: [],
+    // Block data URIs that could contain malicious content
+    ALLOW_DATA_ATTR: false,
+    // Custom hook to further sanitize content
+    HOOKS: {
+      uponSanitizeElement: function(node, data) {
+        // Block any remaining dangerous elements
+        const dangerousTags = ['svg', 'style', 'script', 'link', 'meta', 'iframe', 'object', 'embed', 'applet'];
+        if (node.tagName && dangerousTags.includes(node.tagName.toLowerCase())) {
+          if (process.env.DEBUG === 'true') {
+            console.warn('Blocked potentially dangerous element:', node.tagName);
+          }
+          return false;
+        }
+
+        // Block img tags with SVG data URIs
+        if (node.tagName && node.tagName.toLowerCase() === 'img') {
+          const src = node.getAttribute('src');
+          if (src && (src.startsWith('data:image/svg') || src.endsWith('.svg'))) {
+            if (process.env.DEBUG === 'true') {
+              console.warn('Blocked potentially malicious SVG image:', src);
+            }
+            return false;
+          }
+        }
+
+        // Block elements with dangerous attributes
+        const dangerousAttrs = ['style', 'onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onblur'];
+        for (const attr of dangerousAttrs) {
+          if (node.hasAttribute && node.hasAttribute(attr)) {
+            if (process.env.DEBUG === 'true') {
+              console.warn('Blocked element with dangerous attribute:', node.tagName, attr);
+            }
+            return false;
+          }
+        }
+
+        return true;
+      },
+      uponSanitizeAttribute: function(node, data) {
+        // Block style attributes completely
+        if (data.attrName === 'style') {
+          if (process.env.DEBUG === 'true') {
+            console.warn('Blocked style attribute');
+          }
+          return false;
+        }
+
+        // Block class and id attributes that might be used for CSS injection
+        if (data.attrName === 'class' || data.attrName === 'id') {
+          if (process.env.DEBUG === 'true') {
+            console.warn('Blocked class/id attribute:', data.attrName, data.attrValue);
+          }
+          return false;
+        }
+
+        // Block data attributes
+        if (data.attrName && data.attrName.startsWith('data-')) {
+          if (process.env.DEBUG === 'true') {
+            console.warn('Blocked data attribute:', data.attrName);
+          }
+          return false;
+        }
+
+        return true;
+      }
+    }
+  };
+}
+
+// Convenience function for secure sanitization
+export function sanitizeHTML(html) {
+  return DOMPurify.sanitize(html, getSecureDOMPurifyConfig());
+}
+
+// Convenience function for sanitizing text (no HTML)
+export function sanitizeText(text) {
+  return DOMPurify.sanitize(text, {
+    ALLOWED_TAGS: [],
+    ALLOWED_ATTR: [],
+    KEEP_CONTENT: true
+  });
+}

+ 2 - 1
models/cardComments.js

@@ -1,6 +1,7 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import escapeForRegex from 'escape-string-regexp';
 import DOMPurify from 'dompurify';
+import { sanitizeText } from '/client/lib/secureDOMPurify';
 
 CardComments = new Mongo.Collection('card_comments');
 
@@ -103,7 +104,7 @@ CardComments.helpers({
   },
 
   toggleReaction(reactionCodepoint) {
-    if (reactionCodepoint !== DOMPurify.sanitize(reactionCodepoint)) {
+    if (reactionCodepoint !== sanitizeText(reactionCodepoint)) {
       return false;
     } else {
 

+ 19 - 3
models/cards.js

@@ -1756,10 +1756,20 @@ Cards.helpers({
   },
 
   setTitle(title) {
+    // Sanitize title on client side as well
+    let sanitizedTitle = title;
+    if (typeof title === 'string') {
+      const { sanitizeTitle } = require('/server/lib/inputSanitizer');
+      sanitizedTitle = sanitizeTitle(title);
+      if (process.env.DEBUG === 'true' && sanitizedTitle !== title) {
+        console.warn('Client-side sanitized card title:', title, '->', sanitizedTitle);
+      }
+    }
+
     if (this.isLinkedBoard()) {
-      return Boards.update({ _id: this.linkedId }, { $set: { title } });
+      return Boards.update({ _id: this.linkedId }, { $set: { title: sanitizedTitle } });
     } else {
-      return Cards.update({ _id: this.getRealId() }, { $set: { title } });
+      return Cards.update({ _id: this.getRealId() }, { $set: { title: sanitizedTitle } });
     }
   },
 
@@ -3565,7 +3575,13 @@ JsonRoutes.add('GET', '/api/boards/:boardId/cards_count', function(
       Authentication.checkBoardAccess(req.userId, paramBoardId);
 
       if (req.body.title) {
-        const newTitle = req.body.title;
+        const { sanitizeTitle } = require('/server/lib/inputSanitizer');
+        const newTitle = sanitizeTitle(req.body.title);
+
+        if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
+          console.warn('Sanitized card title input:', req.body.title, '->', newTitle);
+        }
+
         Cards.direct.update(
           {
             _id: paramCardId,

+ 16 - 1
models/lists.js

@@ -313,6 +313,15 @@ Lists.helpers({
 
 Lists.mutations({
   rename(title) {
+    // Sanitize title on client side as well
+    if (typeof title === 'string') {
+      const { sanitizeTitle } = require('/server/lib/inputSanitizer');
+      const sanitizedTitle = sanitizeTitle(title);
+      if (process.env.DEBUG === 'true' && sanitizedTitle !== title) {
+        console.warn('Client-side sanitized list title:', title, '->', sanitizedTitle);
+      }
+      return { $set: { title: sanitizedTitle } };
+    }
     return { $set: { title } };
   },
   star(enable = true) {
@@ -644,7 +653,13 @@ if (Meteor.isServer) {
 
       // Update title if provided
       if (req.body.title) {
-        const newTitle = req.body.title;
+        const { sanitizeTitle } = require('/server/lib/inputSanitizer');
+        const newTitle = sanitizeTitle(req.body.title);
+
+        if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
+          console.warn('Sanitized list title input:', req.body.title, '->', newTitle);
+        }
+
         Lists.direct.update(
           {
             _id: paramListId,

+ 1 - 50
packages/markdown/src/template-integration.js

@@ -1,54 +1,5 @@
 import DOMPurify from 'dompurify';
-
-// Secure DOMPurify configuration to prevent SVG-based DoS attacks
-function getSecureDOMPurifyConfig() {
-  return {
-    // Block dangerous SVG elements that can cause exponential expansion
-    FORBID_TAGS: [
-      'svg', 'defs', 'use', 'g', 'symbol', 'marker', 'pattern', 'mask', 'clipPath',
-      'linearGradient', 'radialGradient', 'stop', 'animate', 'animateTransform',
-      'animateMotion', 'set', 'switch', 'foreignObject', 'script', 'style'
-    ],
-    // Block dangerous SVG attributes
-    FORBID_ATTR: [
-      'xlink:href', 'href', 'onload', 'onerror', 'onclick', 'onmouseover',
-      'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onselect',
-      'onunload', 'onresize', 'onscroll', 'onkeydown', 'onkeyup', 'onkeypress'
-    ],
-    // Allow only safe image formats
-    ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
-    // Remove dangerous protocols
-    ALLOW_UNKNOWN_PROTOCOLS: false,
-    // Sanitize URLs to prevent malicious content loading
-    SANITIZE_DOM: true,
-    // Remove dangerous elements completely
-    KEEP_CONTENT: false,
-    // Additional security measures
-    ADD_ATTR: [],
-    // Block data URIs that could contain malicious SVG
-    ALLOW_DATA_ATTR: false,
-    // Custom hook to further sanitize content
-    HOOKS: {
-      uponSanitizeElement: function(node, data) {
-        // Block any remaining SVG elements
-        if (node.tagName && node.tagName.toLowerCase() === 'svg') {
-          return false;
-        }
-        // Block img tags with SVG data URIs
-        if (node.tagName && node.tagName.toLowerCase() === 'img') {
-          const src = node.getAttribute('src');
-          if (src && (src.startsWith('data:image/svg') || src.endsWith('.svg'))) {
-            if (process.env.DEBUG === 'true') {
-              console.warn('Blocked potentially malicious SVG image:', src);
-            }
-            return false;
-          }
-        }
-        return true;
-      }
-    }
-  };
-}
+import { getSecureDOMPurifyConfig } from '/client/lib/secureDOMPurify';
 
 var Markdown = require('markdown-it')({
   html: true,

+ 74 - 0
server/lib/inputSanitizer.js

@@ -0,0 +1,74 @@
+import DOMPurify from 'dompurify';
+
+// Server-side input sanitization to prevent CSS injection and XSS attacks
+export function sanitizeInput(input) {
+  if (typeof input !== 'string') {
+    return input;
+  }
+
+  // Remove any HTML tags and dangerous content
+  const sanitized = DOMPurify.sanitize(input, {
+    ALLOWED_TAGS: [],
+    ALLOWED_ATTR: [],
+    KEEP_CONTENT: true,
+    FORBID_TAGS: ['style', 'script', 'link', 'meta', 'iframe', 'object', 'embed', 'applet', 'form', 'input', 'textarea', 'select', 'option', 'button', 'label', 'fieldset', 'legend', 'frameset', 'frame', 'noframes', 'base', 'basefont', 'isindex', 'dir', 'menu', 'menuitem', 'svg', 'defs', 'use', 'g', 'symbol', 'marker', 'pattern', 'mask', 'clipPath', 'linearGradient', 'radialGradient', 'stop', 'animate', 'animateTransform', 'animateMotion', 'set', 'switch', 'foreignObject'],
+    FORBID_ATTR: ['style', 'class', 'id', 'onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onselect', 'onunload', 'onresize', 'onscroll', 'onkeydown', 'onkeyup', 'onkeypress', 'onmousedown', 'onmouseup', 'onmouseover', 'onmouseout', 'onmousemove', 'ondblclick', 'oncontextmenu', 'onwheel', 'ontouchstart', 'ontouchend', 'ontouchmove', 'ontouchcancel', 'onabort', 'oncanplay', 'oncanplaythrough', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying', 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled', 'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'onbeforeunload', 'onhashchange', 'onpagehide', 'onpageshow', 'onpopstate', 'onstorage', 'onunload', 'xlink:href', 'href', 'data-*', 'aria-*'],
+    ALLOW_UNKNOWN_PROTOCOLS: false,
+    SANITIZE_DOM: true,
+    KEEP_CONTENT: true,
+    ADD_ATTR: [],
+    ALLOW_DATA_ATTR: false
+  });
+
+  // Additional check for CSS injection patterns
+  const cssInjectionPatterns = [
+    /<style[^>]*>.*?<\/style>/gi,
+    /style\s*=\s*["'][^"']*["']/gi,
+    /@import\s+[^;]+;/gi,
+    /url\s*\(\s*[^)]+\s*\)/gi,
+    /expression\s*\(/gi,
+    /javascript\s*:/gi,
+    /vbscript\s*:/gi,
+    /data\s*:/gi
+  ];
+
+  let cleaned = sanitized;
+  for (const pattern of cssInjectionPatterns) {
+    if (pattern.test(cleaned)) {
+      if (process.env.DEBUG === 'true') {
+        console.warn('Blocked potential CSS injection in input:', cleaned.substring(0, 100) + '...');
+      }
+      // Remove the dangerous content
+      cleaned = cleaned.replace(pattern, '');
+    }
+  }
+
+  return cleaned.trim();
+}
+
+// Specific function for sanitizing titles
+export function sanitizeTitle(title) {
+  if (typeof title !== 'string') {
+    return title;
+  }
+
+  // First sanitize the input
+  let sanitized = sanitizeInput(title);
+
+  // Additional title-specific sanitization
+  // Remove any remaining HTML entities that might be dangerous
+  sanitized = sanitized.replace(/&[#\w]+;/g, '');
+
+  // Remove any remaining angle brackets
+  sanitized = sanitized.replace(/[<>]/g, '');
+
+  // Limit length to prevent abuse
+  if (sanitized.length > 1000) {
+    sanitized = sanitized.substring(0, 1000);
+    if (process.env.DEBUG === 'true') {
+      console.warn('Truncated long title input:', title.length, 'characters');
+    }
+  }
+
+  return sanitized.trim();
+}