mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30: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 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),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
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 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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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
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