diff --git a/models/attachments.js b/models/attachments.js index 39407846c..c648def40 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -148,6 +148,43 @@ if (Meteor.isServer) { }); Meteor.methods({ + // Validate image URL to prevent SVG-based DoS attacks + validateImageUrl(imageUrl) { + check(imageUrl, String); + + if (!imageUrl) { + return { valid: false, reason: 'Empty URL' }; + } + + // Block SVG files and data URIs + if (imageUrl.endsWith('.svg') || imageUrl.startsWith('data:image/svg')) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked potentially malicious SVG image URL:', imageUrl); + } + return { valid: false, reason: 'SVG images are blocked for security reasons' }; + } + + // Block data URIs that could contain malicious content + if (imageUrl.startsWith('data:')) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked data URI image URL:', imageUrl); + } + return { valid: false, reason: 'Data URIs are blocked for security reasons' }; + } + + // Validate URL format + try { + const url = new URL(imageUrl); + // Only allow http and https protocols + if (!['http:', 'https:'].includes(url.protocol)) { + return { valid: false, reason: 'Only HTTP and HTTPS protocols are allowed' }; + } + } catch (e) { + return { valid: false, reason: 'Invalid URL format' }; + } + + return { valid: true }; + }, moveAttachmentToStorage(fileObjId, storageDestination) { check(fileObjId, String); check(storageDestination, String); diff --git a/packages/markdown/src/template-integration.js b/packages/markdown/src/template-integration.js index 16c5cea2f..5a451f834 100644 --- a/packages/markdown/src/template-integration.js +++ b/packages/markdown/src/template-integration.js @@ -1,5 +1,55 @@ 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; + } + } + }; +} + var Markdown = require('markdown-it')({ html: true, linkify: true, @@ -41,6 +91,125 @@ Markdown.use(emoji); var mathjax = require('markdown-it-mathjax3'); Markdown.use(mathjax); +// Custom plugin to prevent SVG-based DoS attacks +Markdown.use(function(md) { + // Filter out dangerous SVG content in markdown + md.core.ruler.push('svg-dos-protection', function(state) { + const tokens = state.tokens; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + // Check for image tokens that might contain SVG + if (token.type === 'image') { + const src = token.attrGet('src'); + if (src) { + // Block SVG data URIs and .svg files + if (src.startsWith('data:image/svg') || src.endsWith('.svg')) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked potentially malicious SVG image in markdown:', src); + } + // Replace with a warning message + token.type = 'paragraph_open'; + token.tag = 'p'; + token.nesting = 1; + token.attrSet('style', 'color: red; background: #ffe6e6; padding: 8px; border: 1px solid #ff9999;'); + token.attrSet('title', 'Blocked potentially malicious SVG image'); + + // Add warning text token + const warningToken = { + type: 'text', + content: '⚠️ Blocked potentially malicious SVG image for security reasons', + level: token.level, + markup: '', + info: '', + meta: null, + block: true, + hidden: false + }; + + // Insert warning token after the paragraph open + tokens.splice(i + 1, 0, warningToken); + + // Add paragraph close token + const closeToken = { + type: 'paragraph_close', + tag: 'p', + nesting: -1, + level: token.level, + markup: '', + info: '', + meta: null, + block: true, + hidden: false + }; + tokens.splice(i + 2, 0, closeToken); + + // Remove the original image token + tokens.splice(i, 1); + i--; // Adjust index since we removed a token + } + } + } + + // Check for HTML tokens that might contain SVG + if (token.type === 'html_block' || token.type === 'html_inline') { + const content = token.content; + if (content && ( + content.includes('') + )) { + if (process.env.DEBUG === 'true') { + console.warn('Blocked potentially malicious SVG content in HTML:', content.substring(0, 100) + '...'); + } + // Replace with warning + token.type = 'paragraph_open'; + token.tag = 'p'; + token.nesting = 1; + token.attrSet('style', 'color: red; background: #ffe6e6; padding: 8px; border: 1px solid #ff9999;'); + token.attrSet('title', 'Blocked potentially malicious SVG content'); + + // Add warning text + const warningToken = { + type: 'text', + content: '⚠️ Blocked potentially malicious SVG content for security reasons', + level: token.level, + markup: '', + info: '', + meta: null, + block: true, + hidden: false + }; + + // Insert warning token after the paragraph open + tokens.splice(i + 1, 0, warningToken); + + // Add paragraph close token + const closeToken = { + type: 'paragraph_close', + tag: 'p', + nesting: -1, + level: token.level, + markup: '', + info: '', + meta: null, + block: true, + hidden: false + }; + tokens.splice(i + 2, 0, closeToken); + + // Remove the original HTML token + tokens.splice(i, 1); + i--; // Adjust index since we removed a token + } + } + } + }); +}); + // Try to fix Mermaid Diagram error: Maximum call stack size exceeded. // Added bigger text size for Diagram. // https://github.com/wekan/wekan/issues/4251 @@ -69,12 +238,12 @@ if (Package.ui) { // Prevent hiding info: https://wekan.github.io/hall-of-fame/invisiblebleed/ // If markdown link does not have description, do not render markdown, instead show all of markdown source code using preformatted text. // Also show html comments. - return HTML.Raw('
' + DOMPurify.sanitize(text.replace('', '-->')) + '
'); + return HTML.Raw('
' + DOMPurify.sanitize(text.replace('', '-->'), getSecureDOMPurifyConfig()) + '
'); } else { // Prevent hiding info: https://wekan.github.io/hall-of-fame/invisiblebleed/ // If text does not have hidden markdown link, render all markdown. // Also show html comments. - return HTML.Raw(DOMPurify.sanitize(Markdown.render(text).replace('', '-->'), {ALLOW_UNKNOWN_PROTOCOLS: true})); + return HTML.Raw(DOMPurify.sanitize(Markdown.render(text).replace('', '-->'), getSecureDOMPurifyConfig())); } })); }