From bd40b0ff695a9b71d12982d2bc2ed8b984437716 Mon Sep 17 00:00:00 2001 From: JackUait Date: Mon, 10 Nov 2025 20:50:34 +0300 Subject: [PATCH] test: add tests for sanitisation --- .../block-tunes/block-tune-move-up.ts | 4 +- src/components/core.ts | 6 +- src/components/dom.ts | 8 +- src/components/modules/blockEvents.ts | 8 +- src/components/modules/blockManager.ts | 18 +- src/components/modules/blockSelection.ts | 6 +- src/components/modules/paste.ts | 18 +- src/components/modules/rectangleSelection.ts | 2 +- src/components/modules/saver.ts | 14 +- src/components/polyfills.ts | 16 +- src/components/selection.ts | 5 +- src/components/utils/sanitizer.ts | 321 +++++- test/playwright/tests/sanitisation.spec.ts | 995 +++++++++++++++++- 13 files changed, 1308 insertions(+), 113 deletions(-) diff --git a/src/components/block-tunes/block-tune-move-up.ts b/src/components/block-tunes/block-tune-move-up.ts index e87d3126..af0fd136 100644 --- a/src/components/block-tunes/block-tune-move-up.ts +++ b/src/components/block-tunes/block-tune-move-up.ts @@ -74,8 +74,8 @@ export default class MoveUpTune implements BlockTune { * - when previous block is visible and has offset from the window, * than we scroll window to the difference between this offsets. */ - const currentBlockCoords = currentBlockElement.getBoundingClientRect(), - previousBlockCoords = previousBlockElement.getBoundingClientRect(); + const currentBlockCoords = currentBlockElement.getBoundingClientRect(); + const previousBlockCoords = previousBlockElement.getBoundingClientRect(); let scrollUpOffset; diff --git a/src/components/core.ts b/src/components/core.ts index dbf9c3af..128ee5a1 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -155,11 +155,7 @@ export default class Core { }; this.config.placeholder = this.config.placeholder ?? false; - this.config.sanitizer = this.config.sanitizer || { - p: true, - b: true, - a: true, - } as SanitizerConfig; + this.config.sanitizer = this.config.sanitizer ?? {} as SanitizerConfig; this.config.hideToolbar = this.config.hideToolbar ?? false; this.config.tools = this.config.tools || {}; diff --git a/src/components/dom.ts b/src/components/dom.ts index 67c06d1c..ac07fb77 100644 --- a/src/components/dom.ts +++ b/src/components/dom.ts @@ -132,8 +132,8 @@ export default class Dom { */ public static swap(el1: HTMLElement, el2: HTMLElement): void { // create marker element and insert it where el1 is - const temp = document.createElement('div'), - parent = el1.parentNode; + const temp = document.createElement('div'); + const parent = el1.parentNode; parent?.insertBefore(temp, el1); @@ -231,8 +231,8 @@ export default class Dom { * * @type {string} */ - const child = atLast ? 'lastChild' : 'firstChild', - sibling = atLast ? 'previousSibling' : 'nextSibling'; + const child = atLast ? 'lastChild' : 'firstChild'; + const sibling = atLast ? 'previousSibling' : 'nextSibling'; if (node === null || node.nodeType !== Node.ELEMENT_NODE) { return node; diff --git a/src/components/modules/blockEvents.ts b/src/components/modules/blockEvents.ts index 40e0973e..cd9546af 100644 --- a/src/components/modules/blockEvents.ts +++ b/src/components/modules/blockEvents.ts @@ -657,10 +657,10 @@ export default class BlockEvents extends Module { * @param {KeyboardEvent} event - keyboard event */ private needToolbarClosing(event: KeyboardEvent): boolean { - const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened), - blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened), - inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened), - flippingToolbarItems = event.keyCode === _.keyCodes.TAB; + const toolboxItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.Toolbar.toolbox.opened); + const blockSettingsItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.BlockSettings.opened); + const inlineToolbarItemSelected = (event.keyCode === _.keyCodes.ENTER && this.Editor.InlineToolbar.opened); + const flippingToolbarItems = event.keyCode === _.keyCodes.TAB; /** * Do not close Toolbar in cases: diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index feb34e5a..d1da4c10 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -9,7 +9,7 @@ import Module from '../__module'; import $ from '../dom'; import * as _ from '../utils'; import Blocks from '../blocks'; -import type { BlockToolData, PasteEvent } from '../../../types'; +import type { BlockToolData, PasteEvent, SanitizerConfig } from '../../../types'; import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import BlockAPI from '../block/api'; import type { BlockMutationEventMap, BlockMutationType } from '../../../types/events/block'; @@ -18,7 +18,7 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; import { BlockChanged } from '../events'; -import { clean, sanitizeBlocks } from '../utils/sanitizer'; +import { clean, composeSanitizerConfig, sanitizeBlocks } from '../utils/sanitizer'; import { convertStringToBlockData, isBlockConvertable } from '../utils/blocks'; import PromiseQueue from '../utils/promise-queue'; @@ -493,7 +493,11 @@ export default class BlockManager extends Module { return; } - const [ cleanData ] = sanitizeBlocks([ blockToMergeDataRaw ], targetBlock.tool.sanitizeConfig); + const [ cleanData ] = sanitizeBlocks( + [ blockToMergeDataRaw ], + targetBlock.tool.sanitizeConfig, + this.config.sanitizer as SanitizerConfig + ); blockToMergeData = cleanData; @@ -689,9 +693,9 @@ export default class BlockManager extends Module { element = element.parentNode as HTMLElement; } - const nodes = this._blocks.nodes, - firstLevelBlock = element.closest(`.${Block.CSS.wrapper}`), - index = nodes.indexOf(firstLevelBlock as HTMLElement); + const nodes = this._blocks.nodes; + const firstLevelBlock = element.closest(`.${Block.CSS.wrapper}`); + const index = nodes.indexOf(firstLevelBlock as HTMLElement); if (index >= 0) { return this._blocks[index]; @@ -857,7 +861,7 @@ export default class BlockManager extends Module { */ const cleanData: string = clean( exportedData, - replacingTool.sanitizeConfig + composeSanitizerConfig(this.config.sanitizer as SanitizerConfig, replacingTool.sanitizeConfig) ); /** diff --git a/src/components/modules/blockSelection.ts b/src/components/modules/blockSelection.ts index a6edcb8c..66fffcb3 100644 --- a/src/components/modules/blockSelection.ts +++ b/src/components/modules/blockSelection.ts @@ -12,7 +12,7 @@ import Shortcuts from '../utils/shortcuts'; import SelectionUtils from '../selection'; import type { SanitizerConfig } from '../../../types/configs'; -import { clean } from '../utils/sanitizer'; +import { clean, composeSanitizerConfig } from '../utils/sanitizer'; /** * @@ -33,7 +33,7 @@ export default class BlockSelection extends Module { * @returns {SanitizerConfig} */ private get sanitizerConfig(): SanitizerConfig { - return { + const baseConfig: SanitizerConfig = { p: {}, h1: {}, h2: {}, @@ -57,6 +57,8 @@ export default class BlockSelection extends Module { i: {}, u: {}, }; + + return composeSanitizerConfig(this.config.sanitizer as SanitizerConfig, baseConfig); } /** diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 6c7ec442..a75f3e87 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -10,7 +10,7 @@ import type { } from '../../../types'; import type Block from '../block'; import type { SavedData } from '../../../types/data-formats'; -import { clean, sanitizeBlocks } from '../utils/sanitizer'; +import { clean, composeSanitizerConfig, sanitizeBlocks } from '../utils/sanitizer'; import type BlockToolAdapter from '../tools/block'; /** @@ -214,7 +214,13 @@ export default class Paste extends Module { return result; }, {} as SanitizerConfig); - const customConfig = Object.assign({}, toolsTags, Tools.getAllInlineToolsSanitizeConfig(), { br: {} }); + const inlineSanitizeConfig = Tools.getAllInlineToolsSanitizeConfig(); + const customConfig = composeSanitizerConfig( + this.config.sanitizer as SanitizerConfig, + toolsTags, + inlineSanitizeConfig, + { br: {} } + ); const cleanData = clean(htmlData, customConfig); /** If there is no HTML or HTML string is equal to plain one, process it as plain text */ @@ -608,7 +614,7 @@ export default class Paste extends Module { return nodes .map((node) => { - let content: HTMLElement | null | undefined, tool = Tools.defaultTool, isBlock = false; + let content: HTMLElement | null | undefined; let tool = Tools.defaultTool; let isBlock = false; switch (node.nodeType) { /** If node is a document fragment, use temp wrapper to get innerHTML */ @@ -897,8 +903,10 @@ export default class Paste extends Module { */ private insertEditorJSData(blocks: Pick[]): void { const { BlockManager, Caret, Tools } = this.Editor; - const sanitizedBlocks = sanitizeBlocks(blocks, (name) => - Tools.blockTools.get(name)?.sanitizeConfig ?? {} + const sanitizedBlocks = sanitizeBlocks( + blocks, + (name) => Tools.blockTools.get(name)?.sanitizeConfig ?? {}, + this.config.sanitizer as SanitizerConfig ); sanitizedBlocks.forEach(({ tool, data }, i) => { diff --git a/src/components/modules/rectangleSelection.ts b/src/components/modules/rectangleSelection.ts index d545036d..20cda624 100644 --- a/src/components/modules/rectangleSelection.ts +++ b/src/components/modules/rectangleSelection.ts @@ -480,7 +480,7 @@ export default class RectangleSelection extends Module { private trySelectNextBlock(index): void { const sameBlock = this.stackOfSelected[this.stackOfSelected.length - 1] === index; const sizeStack = this.stackOfSelected.length; - const down = 1, up = -1, undef = 0; + const down = 1; const up = -1; const undef = 0; if (sameBlock) { return; diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index c0219283..5aff5654 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -6,7 +6,7 @@ * @version 2.0.0 */ import Module from '../__module'; -import type { OutputData } from '../../../types'; +import type { OutputData, SanitizerConfig } from '../../../types'; import type { SavedData, ValidatedData } from '../../../types/data-formats'; import type Block from '../block'; import * as _ from '../utils'; @@ -28,8 +28,8 @@ export default class Saver extends Module { */ public async save(): Promise { const { BlockManager, Tools } = this.Editor; - const blocks = BlockManager.blocks, - chainData = []; + const blocks = BlockManager.blocks; + const chainData = []; try { blocks.forEach((block: Block) => { @@ -37,9 +37,11 @@ export default class Saver extends Module { }); const extractedData = await Promise.all(chainData) as Array>; - const sanitizedData = await sanitizeBlocks(extractedData, (name) => { - return Tools.blockTools.get(name).sanitizeConfig; - }); + const sanitizedData = await sanitizeBlocks( + extractedData, + (name) => Tools.blockTools.get(name).sanitizeConfig, + this.config.sanitizer as SanitizerConfig + ); return this.makeOutput(sanitizedData); } catch (e) { diff --git a/src/components/polyfills.ts b/src/components/polyfills.ts index c8aeac2c..46afb0f3 100644 --- a/src/components/polyfills.ts +++ b/src/components/polyfills.ts @@ -130,14 +130,14 @@ if (typeof Element.prototype.scrollIntoViewIfNeeded === 'undefined') { return; } - const parentComputedStyle = window.getComputedStyle(parent, null), - parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')), - parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')), - overTop = this.offsetTop - parent.offsetTop < parent.scrollTop, - overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight), - overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft, - overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth), - alignWithTop = overTop && !overBottom; + const parentComputedStyle = window.getComputedStyle(parent, null); + const parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')); + const parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')); + const overTop = this.offsetTop - parent.offsetTop < parent.scrollTop; + const overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight); + const overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft; + const overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth); + const alignWithTop = overTop && !overBottom; if ((overTop || overBottom) && centerIfNeeded) { parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2; diff --git a/src/components/selection.ts b/src/components/selection.ts index a86f6ef7..8a507301 100644 --- a/src/components/selection.ts +++ b/src/components/selection.ts @@ -234,8 +234,8 @@ export default class SelectionUtils { * @returns {DOMRect} */ public static get rect(): DOMRect { - let sel: Selection | MSSelection | undefined | null = (document as Document).selection, - range: TextRange | Range; + let sel: Selection | MSSelection | undefined | null = (document as Document).selection; + let range: TextRange | Range; let rect = { x: 0, @@ -414,6 +414,7 @@ export default class SelectionUtils { public removeFakeBackground(): void { if (!this.fakeBackgroundElements.length) { this.isFakeBackgroundEnabled = false; + return; } diff --git a/src/components/utils/sanitizer.ts b/src/components/utils/sanitizer.ts index 69d50149..aaa5af43 100644 --- a/src/components/utils/sanitizer.ts +++ b/src/components/utils/sanitizer.ts @@ -30,9 +30,13 @@ import * as _ from '../utils'; */ import HTMLJanitor from 'html-janitor'; -import type { BlockToolData, SanitizerConfig } from '../../../types'; +import type { BlockToolData, SanitizerConfig, SanitizerRule } from '../../../types'; import type { SavedData } from '../../../types/data-formats'; +type DeepSanitizerRule = SanitizerConfig | SanitizerRule | boolean; + +const UNSAFE_URL_ATTR_PATTERN = /\s*(href|src)\s*=\s*(["']?)\s*(?:javascript:|data:text\/html)[^"' >]*\2/gi; + /** * Sanitize Blocks * @@ -40,23 +44,27 @@ import type { SavedData } from '../../../types/data-formats'; * * @param blocksData - blocks' data to sanitize * @param sanitizeConfig — sanitize config to use or function to get config for Tool + * @param globalSanitizer — global sanitizer config defined on editor level */ -export function sanitizeBlocks( +export const sanitizeBlocks = ( blocksData: Array>, - sanitizeConfig: SanitizerConfig | ((toolName: string) => SanitizerConfig) -): Array> { + sanitizeConfig: SanitizerConfig | ((toolName: string) => SanitizerConfig | undefined), + globalSanitizer: SanitizerConfig = {} as SanitizerConfig +): Array> => { return blocksData.map((block) => { const toolConfig = _.isFunction(sanitizeConfig) ? sanitizeConfig(block.tool) : sanitizeConfig; + const rules = toolConfig ?? ({} as SanitizerConfig); - if (_.isEmpty(toolConfig)) { + if (_.isEmpty(rules) && _.isEmpty(globalSanitizer)) { return block; } - block.data = deepSanitize(block.data, toolConfig) as BlockToolData; - - return block; + return { + ...block, + data: deepSanitize(block.data, rules, globalSanitizer) as BlockToolData, + }; }); -} +}; /** * Cleans string from unwanted tags * Method allows to use default config @@ -65,7 +73,7 @@ export function sanitizeBlocks( * @param {SanitizerConfig} customConfig - allowed tags * @returns {string} clean HTML */ -export function clean(taintString: string, customConfig: SanitizerConfig = {} as SanitizerConfig): string { +export const clean = (taintString: string, customConfig: SanitizerConfig = {} as SanitizerConfig): string => { const sanitizerConfig = { tags: customConfig, }; @@ -76,15 +84,20 @@ export function clean(taintString: string, customConfig: SanitizerConfig = {} as const sanitizerInstance = new HTMLJanitor(sanitizerConfig); return sanitizerInstance.clean(taintString); -} +}; /** * Method recursively reduces Block's data and cleans with passed rules * * @param {BlockToolData|object|*} dataToSanitize - taint string or object/array that contains taint string * @param {SanitizerConfig} rules - object with sanitizer rules + * @param {SanitizerConfig} globalRules - global sanitizer config */ -function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): object | string { +const deepSanitize = ( + dataToSanitize: object | string, + rules: DeepSanitizerRule, + globalRules: SanitizerConfig +): object | string => { /** * BlockData It may contain 3 types: * - Array @@ -95,12 +108,12 @@ function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): /** * Array: call sanitize for each item */ - return cleanArray(dataToSanitize, rules); + return cleanArray(dataToSanitize, rules, globalRules); } else if (_.isObject(dataToSanitize)) { /** * Objects: just clean object deeper. */ - return cleanObject(dataToSanitize, rules); + return cleanObject(dataToSanitize, rules, globalRules); } else { /** * Primitives (number|string|boolean): clean this item @@ -108,32 +121,42 @@ function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): * Clean only strings */ if (_.isString(dataToSanitize)) { - return cleanOneItem(dataToSanitize, rules); + return cleanOneItem(dataToSanitize, rules, globalRules); } return dataToSanitize; } -} +}; /** * Clean array * * @param {Array} array - [1, 2, {}, []] * @param {SanitizerConfig} ruleForItem - sanitizer config for array + * @param globalRules */ -function cleanArray(array: Array, ruleForItem: SanitizerConfig): Array { - return array.map((arrayItem) => deepSanitize(arrayItem, ruleForItem)); -} +const cleanArray = ( + array: Array, + ruleForItem: DeepSanitizerRule, + globalRules: SanitizerConfig +): Array => { + return array.map((arrayItem) => deepSanitize(arrayItem, ruleForItem, globalRules)); +}; /** * Clean object * * @param {object} object - {level: 0, text: 'adada', items: [1,2,3]}} * @param {object} rules - { b: true } or true|false + * @param globalRules * @returns {object} */ -function cleanObject(object: object, rules: SanitizerConfig|{[field: string]: SanitizerConfig}): object { - const cleanData = {}; +const cleanObject = ( + object: object, + rules: DeepSanitizerRule | Record, + globalRules: SanitizerConfig +): object => { + const cleanData: Record = {}; for (const fieldName in object) { if (!Object.prototype.hasOwnProperty.call(object, fieldName)) { @@ -147,30 +170,47 @@ function cleanObject(object: object, rules: SanitizerConfig|{[field: string]: Sa * - if it is a HTML Janitor rule, call with this rule * - otherwise, call with parent's config */ - const ruleForItem = isRule(rules[fieldName] as SanitizerConfig) ? rules[fieldName] : rules; + const rulesRecord = _.isObject(rules) ? (rules as Record) : undefined; + const ruleCandidate = rulesRecord?.[fieldName]; + const ruleForItem = ruleCandidate !== undefined && isRule(ruleCandidate) + ? ruleCandidate + : rules; - cleanData[fieldName] = deepSanitize(currentIterationItem, ruleForItem as SanitizerConfig); + cleanData[fieldName] = deepSanitize(currentIterationItem, ruleForItem as DeepSanitizerRule, globalRules); } return cleanData; -} +}; /** * Clean primitive value * * @param {string} taintString - string to clean * @param {SanitizerConfig|boolean} rule - sanitizer rule + * @param globalRules * @returns {string} */ -function cleanOneItem(taintString: string, rule: SanitizerConfig|boolean): string { - if (_.isObject(rule)) { - return clean(taintString, rule); - } else if (rule === false) { - return clean(taintString, {} as SanitizerConfig); - } else { - return taintString; +const cleanOneItem = ( + taintString: string, + rule: DeepSanitizerRule, + globalRules: SanitizerConfig +): string => { + const effectiveRule = getEffectiveRuleForString(rule, globalRules); + + if (effectiveRule) { + const cleaned = clean(taintString, effectiveRule); + + return stripUnsafeUrls(applyAttributeOverrides(cleaned, effectiveRule)); } -} + + if (!_.isEmpty(globalRules)) { + const cleaned = clean(taintString, globalRules); + + return stripUnsafeUrls(applyAttributeOverrides(cleaned, globalRules)); + } + + return stripUnsafeUrls(taintString); +}; /** * Check if passed item is a HTML Janitor rule: @@ -179,6 +219,219 @@ function cleanOneItem(taintString: string, rule: SanitizerConfig|boolean): strin * * @param {SanitizerConfig} config - config to check */ -function isRule(config: SanitizerConfig): boolean { +const isRule = (config: DeepSanitizerRule): boolean => { return _.isObject(config) || _.isBoolean(config) || _.isFunction(config); -} +}; + +/** + * + * @param value + */ +const stripUnsafeUrls = (value: string): string => { + if (!value || value.indexOf('<') === -1) { + return value; + } + + return value.replace(UNSAFE_URL_ATTR_PATTERN, ''); +}; + +/** + * + * @param config + */ +const cloneSanitizerConfig = (config: SanitizerConfig): SanitizerConfig => { + if (_.isEmpty(config)) { + return {} as SanitizerConfig; + } + + return _.deepMerge({}, config); +}; + +/** + * + * @param rule + */ +const cloneTagConfig = (rule: SanitizerRule): SanitizerRule => { + if (rule === true) { + return {}; + } + + if (rule === false) { + return false; + } + + if (_.isFunction(rule) || _.isString(rule)) { + return rule; + } + + if (_.isObject(rule)) { + return _.deepMerge({}, rule as Record) as SanitizerRule; + } + + return rule; +}; + +/** + * + * @param globalRules + * @param fieldRules + */ +const mergeTagRules = (globalRules: SanitizerConfig, fieldRules: SanitizerConfig): SanitizerConfig => { + if (_.isEmpty(globalRules)) { + return cloneSanitizerConfig(fieldRules); + } + + const merged: SanitizerConfig = {} as SanitizerConfig; + + for (const tag in globalRules) { + if (!Object.prototype.hasOwnProperty.call(globalRules, tag)) { + continue; + } + + const globalValue = globalRules[tag]; + const fieldValue = fieldRules ? fieldRules[tag] : undefined; + + if (_.isFunction(globalValue)) { + merged[tag] = globalValue; + + continue; + } + + if (_.isFunction(fieldValue)) { + merged[tag] = fieldValue; + + continue; + } + + if (_.isObject(globalValue) && _.isObject(fieldValue)) { + merged[tag] = _.deepMerge({}, fieldValue as SanitizerConfig, globalValue as SanitizerConfig); + + continue; + } + + if (fieldValue !== undefined) { + merged[tag] = cloneTagConfig(fieldValue as SanitizerRule); + + continue; + } + + merged[tag] = cloneTagConfig(globalValue as SanitizerRule); + } + + return merged; +}; + +/** + * + * @param rule + * @param globalRules + */ +const getEffectiveRuleForString = ( + rule: DeepSanitizerRule, + globalRules: SanitizerConfig +): SanitizerConfig | null => { + if (_.isObject(rule) && !_.isFunction(rule)) { + return mergeTagRules(globalRules, rule as SanitizerConfig); + } + + if (rule === false) { + return {} as SanitizerConfig; + } + + if (_.isEmpty(globalRules)) { + return null; + } + + return cloneSanitizerConfig(globalRules); +}; + +/** + * + * @param globalConfig + * @param {...any} configs + */ +export const composeSanitizerConfig = ( + globalConfig: SanitizerConfig, + ...configs: SanitizerConfig[] +): SanitizerConfig => { + if (_.isEmpty(globalConfig)) { + return Object.assign({}, ...configs) as SanitizerConfig; + } + + const base = cloneSanitizerConfig(globalConfig); + + configs.forEach((config) => { + if (!config) { + return; + } + + for (const tag in config) { + if (!Object.prototype.hasOwnProperty.call(config, tag)) { + continue; + } + + if (!Object.prototype.hasOwnProperty.call(base, tag)) { + continue; + } + + const sourceValue = config[tag]; + const targetValue = base[tag]; + + if (_.isFunction(sourceValue)) { + base[tag] = sourceValue; + + continue; + } + + if (_.isObject(sourceValue) && _.isObject(targetValue)) { + base[tag] = _.deepMerge({}, targetValue as SanitizerConfig, sourceValue as SanitizerConfig); + + continue; + } + + base[tag] = cloneTagConfig(sourceValue as SanitizerRule); + } + }); + + return base; +}; + +const applyAttributeOverrides = (html: string, rules: SanitizerConfig): string => { + if (typeof document === 'undefined' || !html || html.indexOf('<') === -1) { + return html; + } + + const entries = Object.entries(rules).filter(([, value]) => _.isFunction(value)); + + if (entries.length === 0) { + return html; + } + + const template = document.createElement('template'); + + template.innerHTML = html; + + entries.forEach(([tag, rule]) => { + const elements = template.content.querySelectorAll(tag); + + elements.forEach((element) => { + const ruleResult = (rule as (el: Element) => SanitizerRule)(element); + + if (_.isBoolean(ruleResult) || _.isFunction(ruleResult) || ruleResult == null) { + return; + } + + for (const [attr, attrRule] of Object.entries(ruleResult)) { + if (attrRule === false) { + element.removeAttribute(attr); + } else if (attrRule === true) { + continue; + } else if (_.isString(attrRule)) { + element.setAttribute(attr, attrRule); + } + } + }); + }); + + return template.innerHTML; +}; diff --git a/test/playwright/tests/sanitisation.spec.ts b/test/playwright/tests/sanitisation.spec.ts index 26ff3ac6..6b7d7f5a 100644 --- a/test/playwright/tests/sanitisation.spec.ts +++ b/test/playwright/tests/sanitisation.spec.ts @@ -4,13 +4,13 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import type { OutputData } from '@/types'; import { ensureEditorBundleBuilt } from './helpers/ensure-build'; +import { EDITOR_SELECTOR } from './constants'; const TEST_PAGE_URL = pathToFileURL( path.resolve(__dirname, '../../cypress/fixtures/test.html') ).href; const HOLDER_ID = 'editorjs'; -const EDITOR_SELECTOR = '[data-cy=editorjs]'; const BLOCK_SELECTOR = `${EDITOR_SELECTOR} div.ce-block`; /** @@ -49,6 +49,13 @@ const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): const editor = new window.EditorJS({ holder: holderId, data: { blocks: editorBlocks }, + tools: { + paragraph: { + config: { + preserveBlank: true, + }, + }, + }, }); window.editorInstance = editor; @@ -67,6 +74,13 @@ const createEditor = async (page: Page): Promise => { await page.evaluate(async ({ holderId }) => { const editor = new window.EditorJS({ holder: holderId, + tools: { + paragraph: { + config: { + preserveBlank: true, + }, + }, + }, }); window.editorInstance = editor; @@ -120,6 +134,73 @@ const paste = async (page: Page, locator: Locator, data: Record) })); }; +/** + * Select all text in a block + * + * @param locator - The locator for the block element + */ +const selectAllText = async (locator: Locator): Promise => { + await locator.evaluate((element) => { + const el = element as HTMLElement; + const doc = el.ownerDocument; + const range = doc.createRange(); + const selection = doc.getSelection(); + + if (!selection) { + throw new Error('Selection not available'); + } + + const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const textNodes: Node[] = []; + + while (walker.nextNode()) { + textNodes.push(walker.currentNode); + } + + if (textNodes.length === 0) { + throw new Error('Nothing to select'); + } + + const startNode = textNodes[0]; + const endNode = textNodes[textNodes.length - 1]; + const endOffset = endNode.textContent?.length ?? 0; + + range.setStart(startNode, 0); + range.setEnd(endNode, endOffset); + + selection.removeAllRanges(); + selection.addRange(range); + doc.dispatchEvent(new Event('selectionchange')); + }); +}; + +/** + * Create editor with custom sanitizer config + * + * @param page - The Playwright page object + * @param sanitizerConfig - Custom sanitizer configuration + */ +const createEditorWithSanitizer = async (page: Page, sanitizerConfig: Record): Promise => { + await resetEditor(page); + await page.evaluate(async ({ holderId, sanitizer }) => { + const editor = new window.EditorJS({ + holder: holderId, + sanitizer, + tools: { + paragraph: { + config: { + preserveBlank: true, + }, + }, + }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID, + sanitizer: sanitizerConfig }); +}; + test.describe('Sanitizing', () => { test.beforeAll(() => { ensureEditorBundleBuilt(); @@ -153,38 +234,7 @@ test.describe('Sanitizing', () => { await block.type('This text should be bold.'); // Select all text - await block.evaluate((element) => { - const el = element as HTMLElement; - const doc = el.ownerDocument; - const range = doc.createRange(); - const selection = doc.getSelection(); - - if (!selection) { - throw new Error('Selection not available'); - } - - const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT); - const textNodes: Node[] = []; - - while (walker.nextNode()) { - textNodes.push(walker.currentNode); - } - - if (textNodes.length === 0) { - throw new Error('Nothing to select'); - } - - const startNode = textNodes[0]; - const endNode = textNodes[textNodes.length - 1]; - const endOffset = endNode.textContent?.length ?? 0; - - range.setStart(startNode, 0); - range.setEnd(endNode, endOffset); - - selection.removeAllRanges(); - selection.addRange(range); - doc.dispatchEvent(new Event('selectionchange')); - }); + await selectAllText(block); // Click bold button const boldButton = page.locator(`${EDITOR_SELECTOR} [data-item-name="bold"]`); @@ -249,5 +299,884 @@ test.describe('Sanitizing', () => { // text has been merged, span has been removed expect(blocks[0].data.text).toBe('First blockSecond XSS block'); }); + + test.describe('Other inline tools', () => { + test('should save italic formatting', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'Italic text' }, + }, + ]); + + const output = await saveEditor(page); + + expect(output.blocks[0].data.text).toBe('Italic text'); + }); + + test('should save italic formatting applied via toolbar', async ({ page }) => { + await createEditor(page); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await block.type('This text should be italic.'); + + await selectAllText(block); + + const italicButton = page.locator(`${EDITOR_SELECTOR} [data-item-name="italic"]`); + + await italicButton.click(); + await block.click(); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).toMatch(/This text should be italic\.(
)?<\/i>/); + }); + + test('should save link formatting with href attribute', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'Link text' }, + }, + ]); + + const output = await saveEditor(page); + + expect(output.blocks[0].data.text).toContain(''); + expect(output.blocks[0].data.text).toContain('Link text'); + }); + + test('should save link formatting applied via toolbar', async ({ page }) => { + await createEditor(page); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await block.type('Link text'); + + await selectAllText(block); + + const linkButton = page.locator(`${EDITOR_SELECTOR} [data-item-name="link"]`); + + await linkButton.click(); + + const linkInput = page.locator('input[data-link-tool-input-opened]'); + + await linkInput.fill('https://example.com'); + await linkInput.press('Enter'); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).toMatch(/]*>Link text<\/a>/); + }); + }); + + test.describe('Attribute sanitization', () => { + test('should strip unwanted attributes from links', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'Link' }, + }, + ]); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).toContain('href="https://example.com"'); + expect(text).not.toContain('onclick'); + expect(text).not.toContain('style'); + expect(text).not.toContain('id="malicious"'); + }); + + test('should preserve allowed link attributes', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'Link' }, + }, + ]); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).toContain('href="https://example.com"'); + expect(text).toContain('target="_blank"'); + expect(text).toContain('rel="nofollow"'); + }); + + test('should strip attributes while keeping tags when rule is false', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'Bold' }, + }, + ]); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).toContain(''); + expect(text).toContain('Bold'); + expect(text).not.toContain('style'); + expect(text).not.toContain('onclick'); + }); + }); + + test.describe('XSS prevention', () => { + test('should remove script tags', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'TextMore text' }, + }, + ]); + + const output = await saveEditor(page); + + expect(output.blocks[0].data.text).not.toContain('" />' }, + }, + ]); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).not.toContain('data:text/html'); + expect(text).not.toContain('

', + }); + + const output = await saveEditor(page); + const text = output.blocks[0].data.text; + + expect(text).not.toContain('