import xssModule from "xss"; import type { IFilterXSSOptions } from "xss"; interface IFilterXSSInstance { process(html: string): string; } interface IXssModuleShapes { FilterXSS: new (options?: IFilterXSSOptions) => IFilterXSSInstance; getDefaultWhiteList: () => Record; safeAttrValue: (tag: string, name: string, value: string) => string; } const { FilterXSS, getDefaultWhiteList, safeAttrValue, } = xssModule as unknown as IXssModuleShapes; // Разрешённые протоколы для href и src (строгий белый список) const SAFE_PROTOCOLS = [ "http:", "https:", "mailto:", "tel:" ]; // Все атрибуты, начинающиеся с on (игнорируем регистр), опасны const isEventHandlerAttr = (name: string) => /^on/i.test(name); // Проверка, является ли URL безопасным (относительным или с разрешённым протоколом) function isSafeURL(value: string): boolean { const lower = value.trim().toLowerCase(); if (lower.startsWith("//")) { return false; } // protocol-relative запрещены if ( // eslint-disable-next-line no-script-url lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:") ) { return false; } // Разрешённые протоколы и якоря if (SAFE_PROTOCOLS.some((p) => lower.startsWith(p)) || lower.startsWith("#")) { return true; } // Относительные пути (без ':' до первого слеша) разрешены const firstSlash = lower.indexOf("/"); const colonPos = lower.indexOf(":"); return colonPos === -1 || (firstSlash !== -1 && colonPos > firstSlash); } const safeOnTagAttr = (tag: string, name: string, value: string): string => { const attrNameLower = name.toLowerCase(); // Запрещаем ВСЕ атрибуты, начинающиеся с on (onclick, onerror, onload, …) if (isEventHandlerAttr(attrNameLower)) { return ""; } // Для ссылок и источников проверяем протокол if (attrNameLower === "href" || attrNameLower === "src") { if (!isSafeURL(value)) { return ""; } } // safeAttrValue возвращает только значение, экранируя спецсимволы. // Собираем полный атрибут сами. return `${name}="${safeAttrValue(tag, name, value)}"`; }; // 1. Получаем дефолтный список тегов библиотеки xss const defaultList = getDefaultWhiteList(); // 2. Добавляем все нужные современные HTML5, SVG, компонентные и форменные теги const modernTags = [ // SVG Графика и Спрайты "svg", "defs", "symbol", "path", "use", "g", "circle", "rect", "line", "polyline", "polygon", "ellipse", "lineargradient", "radialgradient", "stop", "mask", "clippath", "text", "tspan", "pattern", "filter", "feGaussianBlur", "feMerge", "feMergeNode", // Веб-компоненты и шаблонизация "template", "slot", // Современные интерактивные и структурные HTML5-теги "dialog", "picture", "source", "main", "header", "footer", "section", "article", "aside", "nav", "figure", "figcaption", "hgroup", "mark", "time", "meter", "progress", "output", "details", "summary", // Медиа-контент "video", "audio", "track", "canvas", // Дополнительные встроенные элементы разметки "ins", // === Формы и элементы ввода === "form", "input", "button", "select", "textarea", "option", "optgroup", "fieldset", "legend", "label", "datalist", ]; // Объединяем дефолтные теги xss и наши новые modernTags в один плоский список const allAllowedTags = Array.from( new Set([ ...Object.keys(defaultList), ...modernTags.map((t) => t.toLowerCase()), ]) ); // 3. Создаем финальный объект белого списка, где у всех тегов атрибуты не ограничены // (фильтрация опасных атрибутов выполняется в onTagAttr) const customWhiteList: Record = {}; allAllowedTags.forEach((tag) => { customWhiteList[tag] = []; }); // Экземпляр санитайзера с безопасными настройками по умолчанию const xssInstance = new FilterXSS({ whiteList: customWhiteList, // Удаляем содержимое тегов script stripIgnoreTagBody: [ "script" ], // Полностью удаляем любые теги, не попавшие в белый список stripIgnoreTag: true, onTagAttr: safeOnTagAttr, }); /** * Получает очищенную строку HTML. * Если передан config, он применяется ПОВЕРХ базовых безопасных настроек, * но критичные фильтры (onTagAttr, экранирование) НЕ могут быть переопределены. * @returns {string} */ export const getSanitizedHTML = (source: string, config?: IFilterXSSOptions): string => { const html = typeof source === "string" ? source : `${source ?? ""}`; if (!html) { return ""; } if (config) { // Безопасный мерж: пользователь может добавить теги/атрибуты, // но опасные переопределения запрещены const customXSS = new FilterXSS({ whiteList: customWhiteList, stripIgnoreTagBody: [ "script" ], stripIgnoreTag: true, ...config, onTagAttr: safeOnTagAttr, // всегда наш обработчик атрибутов onIgnoreTag: undefined, // удаляем неизвестные теги (перестраховка) }); return customXSS.process(html); } return xssInstance.process(html); };