Security Fix JVN#86586539: Stored XSS.

Thanks to Ryoya Koyama of Mitsui Bussan Secure Directions, Inc and xet7.
This commit is contained in:
Lauri Ojansivu 2025-10-10 23:14:06 +03:00
parent a0b94065c5
commit ee79cab7b2
9 changed files with 248 additions and 75 deletions

View file

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

View file

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

View file

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

View file

@ -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
});
}

View file

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

View file

@ -1756,10 +1756,20 @@ Cards.helpers({
}, },
setTitle(title) { 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()) { if (this.isLinkedBoard()) {
return Boards.update({ _id: this.linkedId }, { $set: { title } }); return Boards.update({ _id: this.linkedId }, { $set: { title: sanitizedTitle } });
} else { } 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); Authentication.checkBoardAccess(req.userId, paramBoardId);
if (req.body.title) { 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( Cards.direct.update(
{ {
_id: paramCardId, _id: paramCardId,

View file

@ -313,6 +313,15 @@ Lists.helpers({
Lists.mutations({ Lists.mutations({
rename(title) { 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 } }; return { $set: { title } };
}, },
star(enable = true) { star(enable = true) {
@ -644,7 +653,13 @@ if (Meteor.isServer) {
// Update title if provided // Update title if provided
if (req.body.title) { 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( Lists.direct.update(
{ {
_id: paramListId, _id: paramListId,

View file

@ -1,54 +1,5 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { getSecureDOMPurifyConfig } from '/client/lib/secureDOMPurify';
// 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')({ var Markdown = require('markdown-it')({
html: true, html: true,

View file

@ -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();
}