diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index 439e29619..e6a72cb68 100644 --- a/client/components/activities/activities.js +++ b/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), ), ) ); diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index e2ff2b722..c5719655f 100644 --- a/client/components/cards/attachments.js +++ b/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(); diff --git a/client/components/main/editor.js b/client/components/main/editor.js index fb05a0c00..149dbefbd 100644 --- a/client/components/main/editor.js +++ b/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)); }), ); diff --git a/client/lib/secureDOMPurify.js b/client/lib/secureDOMPurify.js new file mode 100644 index 000000000..c4e352e87 --- /dev/null +++ b/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 + }); +} diff --git a/models/cardComments.js b/models/cardComments.js index eafbe5def..d5ee7e8fc 100644 --- a/models/cardComments.js +++ b/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 { diff --git a/models/cards.js b/models/cards.js index 69a34ad32..7c80072e0 100644 --- a/models/cards.js +++ b/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, diff --git a/models/lists.js b/models/lists.js index 74800aefd..f04e61546 100644 --- a/models/lists.js +++ b/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, diff --git a/packages/markdown/src/template-integration.js b/packages/markdown/src/template-integration.js index 5a451f834..aecd46f2c 100644 --- a/packages/markdown/src/template-integration.js +++ b/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, diff --git a/server/lib/inputSanitizer.js b/server/lib/inputSanitizer.js new file mode 100644 index 000000000..2b9dafcf3 --- /dev/null +++ b/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>/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(); +}