mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 23:40:13 +01:00
Security Fix JVN#86586539: Stored XSS.
Thanks to Ryoya Koyama of Mitsui Bussan Secure Directions, Inc and xet7.
This commit is contained in:
parent
a0b94065c5
commit
ee79cab7b2
9 changed files with 248 additions and 75 deletions
|
|
@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
121
client/lib/secureDOMPurify.js
Normal file
121
client/lib/secureDOMPurify.js
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
74
server/lib/inputSanitizer.js
Normal file
74
server/lib/inputSanitizer.js
Normal 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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue