/** * Blok Sanitizer * * Clears HTML from taint tags * @version 2.0.0 * @example * * clean(yourTaintString, yourConfig); * * {@link SanitizerConfig} */ /** * @typedef {object} SanitizerConfig * @property {object} tags - define tags restrictions * @example * * tags : { * p: true, * a: { * href: true, * rel: "nofollow", * target: "_blank" * } * } */ import HTMLJanitor from 'html-janitor'; import type { BlockToolData, SanitizerConfig, SanitizerRule } from '../../../types'; import type { TagConfig, ToolSanitizerConfig } from '../../../types/configs/sanitizer-config'; import type { SavedData } from '../../../types/data-formats'; import { isSafeAttribute, PLAINTEXT } from '../../shared/sanitize-rules'; import { hasUnsafeUrlProtocol } from '../../shared/url-policy'; import { deepMerge, isBoolean, isEmpty, isFunction, isObject, isString } from '../utils'; import { normalizeInlineMarkupHtml } from './inline-normalization'; type DeepSanitizerRule = SanitizerConfig | SanitizerRule; /** * Re-exported from the pure shared module so existing imports keep working; * see {@link module:src/shared/sanitize-rules} for the definitions. */ export { isSafeAttribute, PLAINTEXT }; /** * Whether a resolved rule declares its field as plaintext. * @param rule - sanitizer rule to test */ const isPlaintextRule = (rule: DeepSanitizerRule): boolean => { return rule === PLAINTEXT; }; /** * Recursive type for data that can contain nested arrays */ type DeepData = string | Record | Array | null; /** * Nesting past this many levels reads back as `null`. Block data comes off a * shared document that any peer can write, and the two recursive walks * below (tag allowlisting, URL-scheme pass) have no other bound. The same * cap governs the doc serializer and the server export, so all three agree * on what a too-deep value becomes. */ const MAX_SANITIZE_DEPTH = 256; /** * Fallback (no-DOM) matcher for href/src attributes: captures the attribute * name and its value so the value can be normalized before the scheme check. */ const URL_ATTR_FALLBACK_PATTERN = /\s*(href|src)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/gi; /** * Sanitize Blocks * * Enumerate blocks and clean data * @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 blok level */ export const sanitizeBlocks = ( blocksData: Array>, sanitizeConfig: SanitizerConfig | ToolSanitizerConfig | ((toolName: string) => SanitizerConfig | ToolSanitizerConfig | undefined), globalSanitizer: SanitizerConfig = {} ): Array> => { return blocksData.map((block) => { const toolConfig = isFunction(sanitizeConfig) ? sanitizeConfig(block.tool) : sanitizeConfig; const rules: DeepSanitizerRule = (toolConfig ?? {}) as SanitizerConfig; if (isObject(rules) && isEmpty(rules) && isEmpty(globalSanitizer)) { /** * Tag allowlisting is opt-in per tool, but URL hardening is not: this * path carries forged `application/x-blok` clipboard JSON, and a tool * that declares no sanitize config still renders `data.text`. * Never hands the caller's object back by reference either. */ return { ...block, data: stripUnsafeUrlsDeep(block.data) }; } return { ...block, data: deepSanitize(block.data, rules, globalSanitizer) as BlockToolData, }; }); }; /** * Cleans string from unwanted tags * Method allows to use default config * @param {string} taintString - taint string * @param {SanitizerConfig} customConfig - allowed tags * @returns {string} clean HTML */ export const clean = (taintString: string, customConfig: SanitizerConfig = {}): string => { /** * PLAINTEXT is a field-level directive, not a tag rule — html-janitor has no * meaning for it. Drop such entries at the boundary so a config carrying one * can never be handed to the parser. */ const tags = Object.fromEntries( Object.entries(customConfig).filter(([, rule]) => !isPlaintextRule(rule)) ) as Record TagConfig)>; const sanitizerConfig = { tags, }; /** * API client can use custom config to manage sanitize process */ const sanitizerInstance = new HTMLJanitor(sanitizerConfig); /** * html-janitor allowlists the `href`/`src` ATTRIBUTE and never looks at its * value, so an allowlisted anchor keeps whatever scheme it carried. The * scheme pass belongs here rather than at each call site: `clean()` is the * public sanitizer (`api.sanitizer.clean`) and every caller that forgot it * shipped a live `javascript:` link. */ return stripUnsafeUrls(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 */ const deepSanitize = ( dataToSanitize: DeepData, rules: DeepSanitizerRule, globalRules: SanitizerConfig, depth = 0 ): DeepData => { if (depth > MAX_SANITIZE_DEPTH) { return null; } /** * BlockData It may contain 3 types: * - Array * - Object * - Primitive */ if (Array.isArray(dataToSanitize)) { /** * Array: call sanitize for each item */ return cleanArray(dataToSanitize, rules, globalRules, depth); } if (isObject(dataToSanitize)) { /** * Objects: just clean object deeper. */ return cleanObject(dataToSanitize, rules, globalRules, depth); } /** * Primitives (number|string|boolean): clean this item * * Clean only strings */ if (isString(dataToSanitize)) { return cleanOneItem(dataToSanitize, rules, globalRules); } return dataToSanitize; }; /** * Clean array * @param {Array} array - [1, 2, {}, []] * @param {SanitizerConfig} ruleForItem - sanitizer config for array * @param {SanitizerConfig} globalRules - global sanitizer config */ const cleanArray = ( array: Array, ruleForItem: DeepSanitizerRule, globalRules: SanitizerConfig, depth: number ): Array => { return array.map((arrayItem) => deepSanitize(arrayItem, ruleForItem, globalRules, depth + 1)); }; /** * Clean object * @param {object} object - {level: 0, text: 'adada', items: [1,2,3]}} * @param {object} rules - { b: true } or true|false * @param {SanitizerConfig} globalRules - global sanitizer config * @returns {object} */ const cleanObject = ( object: Record, rules: DeepSanitizerRule | Record, globalRules: SanitizerConfig, depth: number ): Record => { const cleanData: Record = {}; const objectRecord = object; for (const fieldName in object) { if (!Object.prototype.hasOwnProperty.call(object, fieldName)) { continue; } const currentIterationItem = objectRecord[fieldName]; /** * Get object from config by field name * - if it is a HTML Janitor rule, call with this rule * - otherwise, call with parent's config */ const rulesRecord = isObject(rules) ? (rules as Record) : undefined; const ruleCandidate = rulesRecord?.[fieldName]; const ruleForItem = ruleCandidate !== undefined && isRule(ruleCandidate) ? ruleCandidate : rules; cleanData[fieldName] = deepSanitize(currentIterationItem as DeepData, ruleForItem as DeepSanitizerRule, globalRules, depth + 1); } return cleanData; }; /** * Clean primitive value * @param {string} taintString - string to clean * @param {SanitizerConfig|boolean} rule - sanitizer rule * @param {SanitizerConfig} globalRules - global sanitizer config * @returns {string} */ const cleanOneItem = ( taintString: string, rule: DeepSanitizerRule, globalRules: SanitizerConfig ): string => { /** * Plaintext fields are not markup — parsing them is what corrupts them. * Bypasses the global sanitizer too: a host-level config must not be able * to mangle a field the tool declared as literal text. */ if (isPlaintextRule(rule)) { return taintString; } const effectiveRule = getEffectiveRuleForString(rule, globalRules); if (effectiveRule) { const cleaned = clean(taintString, effectiveRule); return normalizeInlineMarkupHtml(applyAttributeOverrides(cleaned, effectiveRule)); } if (!isEmpty(globalRules)) { const cleaned = clean(taintString, globalRules); return normalizeInlineMarkupHtml(applyAttributeOverrides(cleaned, globalRules)); } return normalizeInlineMarkupHtml(stripUnsafeUrls(taintString)); }; /** * Check if passed item is a HTML Janitor rule: * { a : true }, {}, false, true, function(){} — correct rules * undefined, null, 0, 1, 2 — not a rules * @param {SanitizerConfig} config - config to check */ const isRule = (config: DeepSanitizerRule): boolean => { return isObject(config) || isBoolean(config) || isFunction(config) || isPlaintextRule(config); }; /** * Remove `href`/`src` values whose scheme can execute (`javascript:`, `data:`). * * `clean()` already applies this. Exported for the paths that harden URLs * WITHOUT tag allowlisting — stored block data whose tool declares no sanitize * config (see {@link stripUnsafeUrlsDeep}). * @param value - HTML to harden * @returns the HTML with executable-scheme URL attributes removed */ export const stripUnsafeUrls = (value: string): string => { if (!value || value.indexOf('<') === -1) { return value; } if (typeof document !== 'undefined') { const template = document.createElement('template'); template.innerHTML = value; const unsafe = Array.from(template.content.querySelectorAll('[href],[src]')) .flatMap((element) => ['href', 'src'] .filter((attribute) => hasUnsafeUrlProtocol(element.getAttribute(attribute), attribute)) .map((attribute) => ({ element, attribute }))); /** * The innerHTML round-trip is a parser, not a transform: it entity-encodes * bare `<`/`&` and silently deletes text that looks like a stray end tag. * That destroys plaintext fields (code, captions) which legitimately carry * those characters. Only pay the round-trip when an attribute actually * needs stripping — otherwise the input is returned byte-identical. */ if (unsafe.length === 0) { return value; } unsafe.forEach(({ element, attribute }) => element.removeAttribute(attribute)); return template.innerHTML; } return value.replace( URL_ATTR_FALLBACK_PATTERN, (match, attribute: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => { const url = doubleQuoted ?? singleQuoted ?? unquoted ?? ''; return hasUnsafeUrlProtocol(url, attribute.toLowerCase()) ? '' : match; } ); }; /** * Applies the URL-scheme safety pass to every string in block data, rebuilding * containers along the way. Used by the render path so scheme hardening never * depends on the tool declaring a sanitize config (tag allowlisting stays * opt-in per tool), and so caller-owned data is never retained by reference. * @param data - stored block data */ export const stripUnsafeUrlsDeep = ( data: BlockToolData, rules?: SanitizerConfig | ToolSanitizerConfig ): BlockToolData => { return stripUnsafeUrlsDeepValue(data, rules as DeepSanitizerRule) as BlockToolData; }; const stripUnsafeUrlsDeepValue = (value: DeepData, rules?: DeepSanitizerRule, depth = 0): DeepData => { if (depth > MAX_SANITIZE_DEPTH) { return null; } if (Array.isArray(value)) { return value.map((item) => stripUnsafeUrlsDeepValue(item, rules, depth + 1)); } if (isObject(value)) { const result: Record = {}; const rulesRecord = isObject(rules) ? (rules as Record) : undefined; Object.entries(value).forEach(([key, item]) => { const ruleCandidate = rulesRecord?.[key]; const ruleForItem = ruleCandidate !== undefined && isRule(ruleCandidate) ? ruleCandidate : rules; result[key] = stripUnsafeUrlsDeepValue(item as DeepData, ruleForItem, depth + 1); }); return result; } if (isString(value)) { /** * A PLAINTEXT field carries no URLs to harden — it carries source text * that may merely look like markup. Running the pass would re-introduce * the corruption this sentinel exists to prevent. */ return isPlaintextRule(rules as DeepSanitizerRule) ? value : stripUnsafeUrls(value); } return value; }; /** * * @param {SanitizerConfig} config - sanitizer config to clone */ const cloneSanitizerConfig = (config: SanitizerConfig): SanitizerConfig => { if (isEmpty(config)) { return {}; } const cloned: SanitizerConfig = {}; for (const tag in config) { if (!Object.prototype.hasOwnProperty.call(config, tag)) { continue; } cloned[tag] = cloneTagConfig(config[tag]); } return cloned; }; /** * * @param {SanitizerRule} rule - tag rule to clone */ type SanitizerFunctionRule = (el: Element) => TagConfig; const wrapFunctionRule = (rule: SanitizerFunctionRule): SanitizerFunctionRule => { return function wrappedRule(this: unknown, element: Element): TagConfig { const result = rule.call(this, element); if (result == null) { return {}; } return result; }; }; const preserveExistingAttributesRule: SanitizerFunctionRule = (element) => { const preserved: TagConfig = {}; Array.from(element.attributes).forEach((attribute) => { if (!isSafeAttribute(attribute.name)) { return; } preserved[attribute.name] = true; }); return preserved; }; const cloneTagConfig = (rule: SanitizerRule): SanitizerRule => { if (rule === true) { return wrapFunctionRule(preserveExistingAttributesRule); } if (rule === false) { return false; } if (isFunction(rule)) { return wrapFunctionRule(rule as SanitizerFunctionRule); } if (isString(rule)) { return rule; } if (isObject(rule)) { return deepMerge({}, rule as Record); } return rule; }; /** * * @param {SanitizerConfig} globalRules - global sanitizer config * @param {SanitizerConfig} fieldRules - field-specific sanitizer config */ const mergeTagRules = (globalRules: SanitizerConfig, fieldRules: SanitizerConfig): SanitizerConfig => { if (isEmpty(globalRules)) { return cloneSanitizerConfig(fieldRules); } const merged: SanitizerConfig = {}; for (const tag in globalRules) { if (!Object.prototype.hasOwnProperty.call(globalRules, tag)) { continue; } const globalValue = globalRules[tag]; const fieldValue = fieldRules ? fieldRules[tag] : undefined; /** * A tool's field FUNCTION rule only beats the global rule when the global * rule is itself a function (the field rule is the more specific one). When * the user's global config provides an explicit non-function rule for the * tag (e.g. `span: true`), that deliberate override must win — otherwise a * tool's narrow function (e.g. equation-span) would silently strip what the * user allowed globally. */ if (isFunction(fieldValue) && isFunction(globalValue)) { merged[tag] = cloneTagConfig(fieldValue); continue; } if (isFunction(globalValue)) { merged[tag] = cloneTagConfig(globalValue); continue; } if (isObject(globalValue) && isObject(fieldValue)) { merged[tag] = deepMerge({}, fieldValue as SanitizerConfig, globalValue as SanitizerConfig); continue; } if (fieldValue !== undefined && !isFunction(fieldValue)) { merged[tag] = cloneTagConfig(fieldValue); continue; } merged[tag] = cloneTagConfig(globalValue); } /** * Include tags from field rules that are not present in global rules. * Tool-specific sanitize configs should be able to allow tags * beyond what the global config defines. */ if (!fieldRules) { return merged; } for (const tag in fieldRules) { if (!Object.prototype.hasOwnProperty.call(fieldRules, tag)) { continue; } if (Object.prototype.hasOwnProperty.call(merged, tag)) { continue; } merged[tag] = cloneTagConfig(fieldRules[tag]); } return merged; }; /** * * @param {DeepSanitizerRule} rule - sanitizer rule to evaluate * @param {SanitizerConfig} globalRules - global sanitizer config */ const getEffectiveRuleForString = ( rule: DeepSanitizerRule, globalRules: SanitizerConfig ): SanitizerConfig | null => { if (isObject(rule) && !isFunction(rule)) { return mergeTagRules(globalRules, rule as SanitizerConfig); } if (rule === false) { return {}; } if (isEmpty(globalRules)) { return null; } return cloneSanitizerConfig(globalRules); }; /** * * @param {SanitizerConfig} globalConfig - base global sanitizer config * @param {...SanitizerConfig[]} configs - additional sanitizer configs to compose */ 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; } const sourceValue = config[tag]; /** * If the tag doesn't exist in base, skip it to respect the base config */ if (!Object.prototype.hasOwnProperty.call(base, tag)) { continue; } const targetValue = base[tag]; if (isFunction(sourceValue)) { base[tag] = sourceValue; continue; } if (sourceValue === true && isFunction(targetValue)) { continue; } if (sourceValue === true) { const targetIsPlainObject = isObject(targetValue) && !isFunction(targetValue); base[tag] = targetIsPlainObject ? deepMerge({}, targetValue as SanitizerConfig) : cloneTagConfig(sourceValue); continue; } if (isObject(sourceValue) && isObject(targetValue)) { base[tag] = deepMerge({}, targetValue as SanitizerConfig, sourceValue as SanitizerConfig); continue; } base[tag] = cloneTagConfig(sourceValue); } }); 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); continue; } if (attrRule === true) { continue; } if (isString(attrRule)) { element.setAttribute(attr, attrRule); } } }); }); return template.innerHTML; };