test: add tests for sanitisation

This commit is contained in:
JackUait 2025-11-10 20:50:34 +03:00
commit bd40b0ff69
13 changed files with 1308 additions and 113 deletions

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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)
);
/**

View file

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

View file

@ -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<SavedData, 'id' | 'data' | 'tool'>[]): 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) => {

View file

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

View file

@ -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<OutputData> {
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<Pick<SavedData, 'data' | 'tool'>>;
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) {

View file

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

View file

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

View file

@ -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<Pick<SavedData, 'data' | 'tool'>>,
sanitizeConfig: SanitizerConfig | ((toolName: string) => SanitizerConfig)
): Array<Pick<SavedData, 'data' | 'tool'>> {
sanitizeConfig: SanitizerConfig | ((toolName: string) => SanitizerConfig | undefined),
globalSanitizer: SanitizerConfig = {} as SanitizerConfig
): Array<Pick<SavedData, 'data' | 'tool'>> => {
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<object | string>, ruleForItem: SanitizerConfig): Array<object | string> {
return array.map((arrayItem) => deepSanitize(arrayItem, ruleForItem));
}
const cleanArray = (
array: Array<object | string>,
ruleForItem: DeepSanitizerRule,
globalRules: SanitizerConfig
): Array<object | string> => {
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<string, DeepSanitizerRule>,
globalRules: SanitizerConfig
): object => {
const cleanData: Record<string, unknown> = {};
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<string, DeepSanitizerRule>) : 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<string, unknown>) 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;
};

File diff suppressed because it is too large Load diff