/**
* HTML sanitization pre-pass. The ProseMirror schema is the real whitelist —
* anything it can't parse is dropped — but this pass removes actively
* dangerous content (scripts, event handlers, javascript: URLs) before the
* string ever touches a live DOM, and strips style declarations down to the
* set the schema understands.
*/
import { isAllowedEmbedSrc, EMBED_SANDBOX, EMBED_REFERRER_POLICY } from './plugins/embeds';
/** Elements removed together with their content. */
const DROP_WITH_CONTENT = new Set([
'script', 'frame', 'frameset', 'object', 'embed',
'applet', 'form', 'input', 'textarea', 'select', 'button', 'meta', 'link',
'base', 'title', 'noscript', 'template', 'svg', 'math', 'canvas', 'audio',
'video', 'dialog', 'slot',
]);
/** Elements kept as-is (with filtered attributes). Extended in later phases. */
const ALLOWED_TAGS = new Set([
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'li',
'br', 'hr', 'a', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del',
'span', 'code', 'sub', 'sup', 'div', 'font',
'pre', 'figure', 'figcaption', 'img', 'iframe',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col',
]);
/** Attributes allowed per tag (on top of GLOBAL_ATTRS). */
const TAG_ATTRS: Record> = {
a: new Set(['href', 'title', 'target', 'rel', 'id', 'name', 'data-bookmark']),
table: new Set(['role', 'data-layout']),
ol: new Set(['start', 'type', 'data-list-style']),
ul: new Set(['data-task-list']),
figure: new Set(['data-align']),
li: new Set(['data-task-item', 'data-checked']),
span: new Set([
'data-mention-trigger', 'data-mention-key', 'data-mention-label',
'data-merge-field', 'data-merge-label', 'data-math', 'data-math-display',
]),
pre: new Set(['data-language']),
img: new Set(['src', 'alt', 'title', 'width', 'height']),
td: new Set(['colspan', 'rowspan', 'data-colwidth']),
th: new Set(['colspan', 'rowspan', 'data-colwidth']),
col: new Set(['span', 'width']),
div: new Set(['data-embed-provider', 'data-width', 'data-align']),
iframe: new Set([
'src', 'title', 'frameborder', 'allowfullscreen', 'allow', 'sandbox', 'referrerpolicy',
]),
};
const GLOBAL_ATTRS = new Set(['style', 'align', 'class', 'id']);
/** Author class/id names starting with these are the editor's own; drop them so
* content can't impersonate or restyle the chrome. */
const RESERVED_NAME = /^(?:nile-wysiwyg|ProseMirror)/;
/** Filtered `class`: reserved-prefixed tokens removed. Empty → drop attribute. */
function cleanClassAttr(value: string): string {
return value
.split(/\s+/)
.filter(token => token && !RESERVED_NAME.test(token))
.join(' ');
}
/** Style declarations the schema can represent. Everything else is noise. */
const ALLOWED_STYLES = new Set([
'color', 'background-color', 'font-family', 'font-size', 'font-style',
'font-weight', 'text-align', 'text-decoration', 'text-decoration-line',
'vertical-align', 'margin-left', 'margin-inline-start', 'list-style-type',
'line-height',
]);
const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'ftp:'];
export function isSafeUrl(url: string, allowDataImage = false): boolean {
const trimmed = url.trim();
if (trimmed === '') return false;
// Relative URLs and fragments have no protocol — safe.
if (/^(#|\/|\.\/|\.\.\/)/.test(trimmed)) return true;
const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
if (!match) return true;
const protocol = `${match[1].toLowerCase()}:`;
if (SAFE_URL_PROTOCOLS.includes(protocol)) return true;
if (allowDataImage && protocol === 'data:') {
// Raster images only — data:image/svg+xml can carry scripts.
return /^data:image\/(png|gif|jpe?g|webp|avif|bmp);/i.test(trimmed);
}
return false;
}
/** Size styles allowed only where they are part of the document model. */
const SIZED_TAGS: Record> = {
img: new Set(['width', 'height']),
figure: new Set(['width', 'margin-right']),
div: new Set(['width', 'max-width']),
};
function filterStyle(value: string, tag: string): string {
const extra = SIZED_TAGS[tag];
return value
.split(';')
.map(decl => decl.trim())
.filter(decl => {
const name = decl.slice(0, decl.indexOf(':')).trim().toLowerCase();
if (!ALLOWED_STYLES.has(name) && !(extra && extra.has(name))) return false;
// Defense in depth: CSS url()/expression() smuggling.
return !/url\s*\(|expression\s*\(/i.test(decl);
})
.join('; ');
}
/**
* Sanitizes the CSS inside a {
const url = String(raw).trim();
if (/^#/.test(url)) return full;
if (/^data:image\/(png|gif|jpe?g|webp|avif|bmp);/i.test(url)) return full;
return 'url(#)';
})
.trim();
}
function sanitizeElement(el: Element): void {
const tag = el.tagName.toLowerCase();
const allowed = TAG_ATTRS[tag];
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const keep =
(GLOBAL_ATTRS.has(name) || (allowed !== undefined && allowed.has(name))) &&
!name.startsWith('on');
if (!keep) {
el.removeAttribute(attr.name);
continue;
}
if (name === 'style') {
const filtered = filterStyle(attr.value, tag);
if (filtered) el.setAttribute('style', filtered);
else el.removeAttribute('style');
} else if (name === 'class') {
const cleaned = cleanClassAttr(attr.value);
if (cleaned) el.setAttribute('class', cleaned);
else el.removeAttribute('class');
} else if (name === 'id') {
if (RESERVED_NAME.test(attr.value)) el.removeAttribute('id');
} else if (name === 'href' || name === 'src') {
// Raster data: URLs are allowed on images only.
if (!isSafeUrl(attr.value, tag === 'img')) el.removeAttribute(attr.name);
} else if (name === 'target') {
if (attr.value === '_blank') el.setAttribute('rel', 'noopener noreferrer');
else el.removeAttribute('target');
}
}
}
function walk(node: Element): void {
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase();
if (DROP_WITH_CONTENT.has(tag)) {
child.remove();
continue;
}
// iframes are allowed for any https src, but always force-sandboxed so an
// untrusted embed can never navigate the top window or run unrestricted.
if (tag === 'iframe') {
if (!isAllowedEmbedSrc(child.getAttribute('src') ?? '')) {
child.remove();
continue;
}
child.setAttribute('sandbox', EMBED_SANDBOX);
child.setAttribute('referrerpolicy', EMBED_REFERRER_POLICY);
}
//