minisiwyg-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/editor.d.ts +11 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +7 -0
- package/dist/policy.cjs +2 -0
- package/dist/policy.cjs.map +7 -0
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +2 -0
- package/dist/policy.js.map +7 -0
- package/dist/sanitize.cjs +2 -0
- package/dist/sanitize.cjs.map +7 -0
- package/dist/sanitize.d.ts +16 -0
- package/dist/sanitize.js +2 -0
- package/dist/sanitize.js.map +7 -0
- package/dist/shared.d.ts +16 -0
- package/dist/toolbar.cjs +2 -0
- package/dist/toolbar.cjs.map +7 -0
- package/dist/toolbar.d.ts +10 -0
- package/dist/toolbar.js +2 -0
- package/dist/toolbar.js.map +7 -0
- package/dist/types.d.ts +37 -0
- package/package.json +75 -0
- package/src/defaults.ts +32 -0
- package/src/editor.ts +376 -0
- package/src/index.ts +13 -0
- package/src/policy.ts +226 -0
- package/src/sanitize.ts +169 -0
- package/src/shared.ts +44 -0
- package/src/toolbar.css +43 -0
- package/src/toolbar.ts +159 -0
- package/src/types.ts +41 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/defaults.ts", "../src/shared.ts", "../src/sanitize.ts", "../src/policy.ts", "../src/editor.ts", "../src/toolbar.ts"],
|
|
4
|
+
"sourcesContent": ["import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n", "/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\n/**\n * Walk a DOM tree depth-first and sanitize according to policy.\n * Mutates the tree in place.\n */\nfunction walkAndSanitize(\n parent: Node,\n policy: SanitizePolicy,\n depth: number,\n): void {\n const children = Array.from(parent.childNodes);\n\n for (const node of children) {\n // Text nodes: always allowed (length enforcement happens after walk)\n if (node.nodeType === 3) continue;\n\n // Non-element, non-text nodes (comments, processing instructions): remove\n if (node.nodeType !== 1) {\n parent.removeChild(node);\n continue;\n }\n\n const el = node as Element;\n let tagName = el.tagName.toLowerCase();\n\n // Normalize tags (b\u2192strong, i\u2192em)\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) {\n tagName = normalized;\n }\n\n // Check depth limit\n if (depth >= policy.maxDepth) {\n parent.removeChild(el);\n continue;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n // Tag not allowed\n if (policy.strip) {\n // Remove the node and all its children\n parent.removeChild(el);\n } else {\n // Unwrap: sanitize children first, then move them up\n walkAndSanitize(el, policy, depth);\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n continue;\n }\n\n // Tag is allowed. If it was normalized, replace with correct element.\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const doc = el.ownerDocument!;\n const replacement = doc.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n parent.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n const attrs = Array.from(current.attributes);\n for (const attr of attrs) {\n const attrName = attr.name.toLowerCase();\n\n // Always strip event handlers (on*)\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Check attribute whitelist\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Validate URL protocols on URL-bearing attributes\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n // Recurse into children\n walkAndSanitize(current, policy, depth + 1);\n }\n}\n\n/**\n * Sanitize an HTML string and return a DocumentFragment.\n * Avoids the serialize\u2192reparse round-trip that can cause mXSS.\n */\nexport function sanitizeToFragment(html: string, policy: SanitizePolicy): DocumentFragment {\n const template = document.createElement('template');\n if (!html) return template.content;\n\n template.innerHTML = html;\n const fragment = template.content;\n\n walkAndSanitize(fragment, policy, 0);\n\n if (policy.maxLength > 0 && (fragment.textContent?.length ?? 0) > policy.maxLength) {\n truncateToLength(fragment, policy.maxLength);\n }\n\n return fragment;\n}\n\n/**\n * Sanitize an HTML string according to the given policy.\n *\n * Uses a <template> element to parse HTML without executing scripts.\n * Walks the resulting DOM tree depth-first, removing disallowed elements\n * and attributes. Returns the sanitized HTML string.\n */\nexport function sanitize(html: string, policy: SanitizePolicy): string {\n if (!html) return '';\n\n const fragment = sanitizeToFragment(html, policy);\n const container = document.createElement('div');\n container.appendChild(fragment);\n return container.innerHTML;\n}\n\n/**\n * Truncate a DOM tree's text content to a maximum length.\n * Removes nodes beyond the limit while preserving structure.\n */\nfunction truncateToLength(node: Node, maxLength: number): number {\n let remaining = maxLength;\n\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (remaining <= 0) {\n node.removeChild(child);\n continue;\n }\n\n if (child.nodeType === 3) {\n // Text node\n const text = child.textContent ?? '';\n if (text.length > remaining) {\n child.textContent = text.slice(0, remaining);\n remaining = 0;\n } else {\n remaining -= text.length;\n }\n } else if (child.nodeType === 1) {\n remaining = truncateToLength(child, remaining);\n } else {\n node.removeChild(child);\n }\n }\n\n return remaining;\n}\n", "import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\n\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\nexport interface PolicyEnforcer {\n destroy(): void;\n on(event: 'error', handler: (error: Error) => void): void;\n}\n\n/**\n * Get the nesting depth of a node within a root element.\n */\nfunction getDepth(node: Node, root: Node): number {\n let depth = 0;\n let current = node.parentNode;\n while (current && current !== root) {\n if (current.nodeType === 1) depth++;\n current = current.parentNode;\n }\n return depth;\n}\n\n/**\n * Check if an element is allowed by the policy and fix it if not.\n * Returns true if the node was removed/replaced.\n */\nfunction enforceElement(\n el: Element,\n policy: SanitizePolicy,\n root: HTMLElement,\n): boolean {\n let tagName = el.tagName.toLowerCase();\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) tagName = normalized;\n\n // Check depth\n const depth = getDepth(el, root);\n if (depth >= policy.maxDepth) {\n el.parentNode?.removeChild(el);\n return true;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n if (policy.strip) {\n el.parentNode?.removeChild(el);\n } else {\n // Unwrap: move children up, then remove the element\n const parent = el.parentNode;\n if (parent) {\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n }\n return true;\n }\n\n // Normalize tag if needed (e.g. <b> \u2192 <strong>)\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const replacement = el.ownerDocument.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n // Copy allowed attributes\n for (const attr of Array.from(el.attributes)) {\n replacement.setAttribute(attr.name, attr.value);\n }\n el.parentNode?.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n for (const attr of Array.from(current.attributes)) {\n const attrName = attr.name.toLowerCase();\n\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n return false;\n}\n\n/**\n * Recursively enforce policy on all descendants of a node.\n */\nfunction enforceSubtree(node: Node, policy: SanitizePolicy, root: HTMLElement): void {\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (child.nodeType !== 1) {\n // Remove non-text, non-element nodes (comments, etc.)\n if (child.nodeType !== 3) {\n node.removeChild(child);\n }\n continue;\n }\n const removed = enforceElement(child as Element, policy, root);\n if (!removed) {\n enforceSubtree(child, policy, root);\n }\n }\n}\n\n/**\n * Create a policy enforcer that uses MutationObserver to enforce\n * the sanitization policy on a live DOM element.\n *\n * This is defense-in-depth \u2014 the paste handler is the primary security boundary.\n * The observer catches mutations from execCommand, programmatic DOM manipulation,\n * and other sources.\n */\nexport function createPolicyEnforcer(\n element: HTMLElement,\n policy: SanitizePolicy,\n): PolicyEnforcer {\n if (!policy || !policy.tags) {\n throw new TypeError('Policy must have a \"tags\" property');\n }\n\n let isApplyingFix = false;\n const errorHandlers: Array<(error: Error) => void> = [];\n\n function emitError(error: Error): void {\n for (const handler of errorHandlers) {\n handler(error);\n }\n }\n\n const observer = new MutationObserver((mutations) => {\n if (isApplyingFix) return;\n isApplyingFix = true;\n\n try {\n for (const mutation of mutations) {\n if (mutation.type === 'childList') {\n for (const node of Array.from(mutation.addedNodes)) {\n // Skip text nodes\n if (node.nodeType === 3) continue;\n\n // Remove non-element nodes\n if (node.nodeType !== 1) {\n node.parentNode?.removeChild(node);\n continue;\n }\n\n const removed = enforceElement(node as Element, policy, element);\n if (!removed) {\n // Also enforce on all descendants of the added node\n enforceSubtree(node, policy, element);\n }\n }\n } else if (mutation.type === 'attributes') {\n const target = mutation.target as Element;\n if (target.nodeType !== 1) continue;\n\n const attrName = mutation.attributeName;\n if (!attrName) continue;\n\n const tagName = target.tagName.toLowerCase();\n const normalizedTag = TAG_NORMALIZE[tagName] || tagName;\n const allowedAttrs = policy.tags[normalizedTag];\n\n if (!allowedAttrs) continue;\n\n const lowerAttr = attrName.toLowerCase();\n\n if (lowerAttr.startsWith('on')) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (!allowedAttrs.includes(lowerAttr)) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (URL_ATTRS.has(lowerAttr)) {\n const value = target.getAttribute(attrName);\n if (value && !isProtocolAllowed(value, policy.protocols)) {\n target.removeAttribute(attrName);\n }\n }\n }\n }\n } catch (err) {\n emitError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n isApplyingFix = false;\n }\n });\n\n observer.observe(element, {\n childList: true,\n attributes: true,\n subtree: true,\n });\n\n return {\n destroy() {\n observer.disconnect();\n },\n on(event: 'error', handler: (error: Error) => void) {\n if (event === 'error') {\n errorHandlers.push(handler);\n }\n },\n };\n}\n", "import type { SanitizePolicy, EditorOptions, Editor } from './types';\nimport { DEFAULT_POLICY } from './defaults';\nimport { sanitizeToFragment } from './sanitize';\nimport { createPolicyEnforcer, type PolicyEnforcer } from './policy';\nimport { isProtocolAllowed } from './shared';\n\nexport type { Editor, EditorOptions } from './types';\nexport { DEFAULT_POLICY } from './defaults';\n\ntype EditorEvent = 'change' | 'paste' | 'overflow' | 'error';\ntype EventHandler = (...args: unknown[]) => void;\n\nconst SUPPORTED_COMMANDS = new Set([\n 'bold',\n 'italic',\n 'heading',\n 'blockquote',\n 'unorderedList',\n 'orderedList',\n 'link',\n 'unlink',\n 'codeBlock',\n]);\n\n/**\n * Create a contentEditable-based editor with built-in sanitization.\n *\n * The paste handler is the primary security boundary \u2014 it sanitizes HTML\n * before insertion via Selection/Range API. The MutationObserver-based\n * policy enforcer provides defense-in-depth.\n */\nexport function createEditor(\n element: HTMLElement,\n options?: EditorOptions,\n): Editor {\n if (!element) {\n throw new TypeError('createEditor requires an HTMLElement');\n }\n if (!element.ownerDocument || !element.parentNode) {\n throw new TypeError('createEditor requires an element attached to the DOM');\n }\n\n const src = options?.policy ?? DEFAULT_POLICY;\n const policy: SanitizePolicy = {\n tags: Object.fromEntries(\n Object.entries(src.tags).map(([k, v]) => [k, [...v]]),\n ),\n strip: src.strip,\n maxDepth: src.maxDepth,\n maxLength: src.maxLength,\n protocols: [...src.protocols],\n };\n\n const handlers: Record<string, EventHandler[]> = {};\n const doc = element.ownerDocument;\n\n function emit(event: EditorEvent, ...args: unknown[]): void {\n for (const handler of handlers[event] ?? []) {\n handler(...args);\n }\n }\n\n // Set up contentEditable\n element.contentEditable = 'true';\n\n // Attach policy enforcer (MutationObserver defense-in-depth)\n const enforcer: PolicyEnforcer = createPolicyEnforcer(element, policy);\n enforcer.on('error', (err) => emit('error', err));\n\n // Paste handler \u2014 the primary security boundary\n function onPaste(e: ClipboardEvent): void {\n e.preventDefault();\n\n const clipboard = e.clipboardData;\n if (!clipboard) return;\n\n // Inside code block: paste as plain text only\n const sel = doc.getSelection();\n if (sel && sel.rangeCount > 0 && sel.anchorNode) {\n const pre = findAncestor(sel.anchorNode, 'PRE');\n if (pre) {\n const text = clipboard.getData('text/plain');\n if (!text) return;\n if (policy.maxLength > 0) {\n const currentLen = element.textContent?.length ?? 0;\n if (currentLen + text.length > policy.maxLength) {\n emit('overflow', policy.maxLength);\n }\n }\n const range = sel.getRangeAt(0);\n range.deleteContents();\n const textNode = doc.createTextNode(text);\n range.insertNode(textNode);\n range.setStartAfter(textNode);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n emit('paste', element.innerHTML);\n emit('change', element.innerHTML);\n return;\n }\n }\n\n // Prefer HTML, fall back to plain text\n let html = clipboard.getData('text/html');\n if (!html) {\n const text = clipboard.getData('text/plain');\n if (!text) return;\n // Escape plain text and convert newlines to <br>\n html = text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n .replace(/\\n/g, '<br>');\n }\n\n // Sanitize through policy \u2014 returns DocumentFragment directly\n // to avoid the serialize\u2192reparse mXSS vector\n const fragment = sanitizeToFragment(html, policy);\n\n // Insert via Selection/Range API (NOT execCommand('insertHTML'))\n const selection = doc.getSelection();\n if (!selection || selection.rangeCount === 0) return;\n\n const range = selection.getRangeAt(0);\n range.deleteContents();\n\n // Check overflow using text content length\n if (policy.maxLength > 0) {\n const pasteTextLen = fragment.textContent?.length ?? 0;\n const currentLen = element.textContent?.length ?? 0;\n if (currentLen + pasteTextLen > policy.maxLength) {\n emit('overflow', policy.maxLength);\n }\n }\n\n // Remember last inserted node for cursor positioning\n let lastNode: Node | null = fragment.lastChild;\n range.insertNode(fragment);\n\n // Move cursor after inserted content\n if (lastNode) {\n const newRange = doc.createRange();\n newRange.setStartAfter(lastNode);\n newRange.collapse(true);\n selection.removeAllRanges();\n selection.addRange(newRange);\n }\n\n emit('paste', element.innerHTML);\n emit('change', element.innerHTML);\n }\n\n // Input handler for change events\n function onInput(): void {\n emit('change', element.innerHTML);\n options?.onChange?.(element.innerHTML);\n }\n\n // Keydown handler for code block behavior\n function onKeydown(e: KeyboardEvent): void {\n const sel = doc.getSelection();\n if (!sel || sel.rangeCount === 0) return;\n const anchor = sel.anchorNode;\n if (!anchor) return;\n\n const pre = findAncestor(anchor, 'PRE');\n\n if (e.key === 'Enter' && pre) {\n // Insert newline instead of new paragraph\n e.preventDefault();\n const range = sel.getRangeAt(0);\n range.deleteContents();\n const textNode = doc.createTextNode('\\n');\n range.insertNode(textNode);\n range.setStartAfter(textNode);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n emit('change', element.innerHTML);\n }\n\n if (e.key === 'Backspace' && pre) {\n // At start of empty pre, convert to <p>\n const text = pre.textContent || '';\n const isAtStart = sel.anchorOffset === 0;\n const isEmpty = text === '' || text === '\\n';\n if (isAtStart && isEmpty) {\n e.preventDefault();\n const p = doc.createElement('p');\n p.appendChild(doc.createElement('br'));\n pre.parentNode?.replaceChild(p, pre);\n const range = doc.createRange();\n range.selectNodeContents(p);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n emit('change', element.innerHTML);\n }\n }\n }\n\n element.addEventListener('keydown', onKeydown);\n element.addEventListener('paste', onPaste);\n element.addEventListener('input', onInput);\n\n function findAncestor(node: Node, tagName: string): Element | null {\n let current: Node | null = node;\n while (current && current !== element) {\n if (current.nodeType === 1 && (current as Element).tagName === tagName) return current as Element;\n current = current.parentNode;\n }\n return null;\n }\n\n function hasAncestor(node: Node, tagName: string): boolean {\n let current: Node | null = node;\n while (current && current !== element) {\n if (current.nodeType === 1 && (current as Element).tagName === tagName) return true;\n current = current.parentNode;\n }\n return false;\n }\n\n const editor: Editor = {\n exec(command: string, value?: string): void {\n if (!SUPPORTED_COMMANDS.has(command)) {\n throw new Error(`Unknown editor command: \"${command}\"`);\n }\n\n element.focus();\n\n switch (command) {\n case 'bold':\n doc.execCommand('bold', false);\n break;\n case 'italic':\n doc.execCommand('italic', false);\n break;\n case 'heading': {\n const level = value ?? '1';\n if (!['1', '2', '3'].includes(level)) {\n throw new Error(`Invalid heading level: \"${level}\". Use 1, 2, or 3`);\n }\n doc.execCommand('formatBlock', false, `<h${level}>`);\n break;\n }\n case 'blockquote':\n doc.execCommand('formatBlock', false, '<blockquote>');\n break;\n case 'unorderedList':\n doc.execCommand('insertUnorderedList', false);\n break;\n case 'orderedList':\n doc.execCommand('insertOrderedList', false);\n break;\n case 'link': {\n if (!value) {\n throw new Error('Link command requires a URL value');\n }\n const trimmed = value.trim();\n if (!isProtocolAllowed(trimmed, policy.protocols)) {\n emit('error', new Error(`Protocol not allowed: ${trimmed}`));\n return;\n }\n doc.execCommand('createLink', false, trimmed);\n break;\n }\n case 'unlink':\n doc.execCommand('unlink', false);\n break;\n case 'codeBlock': {\n const sel = doc.getSelection();\n if (!sel || sel.rangeCount === 0) break;\n const anchor = sel.anchorNode;\n const pre = anchor ? findAncestor(anchor, 'PRE') : null;\n if (pre) {\n // Toggle off: unwrap <pre><code> to <p>\n const p = doc.createElement('p');\n p.textContent = pre.textContent || '';\n pre.parentNode?.replaceChild(p, pre);\n const r = doc.createRange();\n r.selectNodeContents(p);\n r.collapse(false);\n sel.removeAllRanges();\n sel.addRange(r);\n } else {\n // Wrap current block in <pre><code>\n const range = sel.getRangeAt(0);\n let block = range.startContainer;\n while (block.parentNode && block.parentNode !== element) {\n block = block.parentNode;\n }\n const pre2 = doc.createElement('pre');\n const code = doc.createElement('code');\n const blockText = block.textContent || '';\n code.textContent = blockText.endsWith('\\n') ? blockText : blockText + '\\n';\n pre2.appendChild(code);\n if (block.parentNode === element) {\n element.replaceChild(pre2, block);\n } else {\n element.appendChild(pre2);\n }\n const r = doc.createRange();\n r.selectNodeContents(code);\n r.collapse(false);\n sel.removeAllRanges();\n sel.addRange(r);\n }\n emit('change', element.innerHTML);\n break;\n }\n }\n },\n\n queryState(command: string): boolean {\n if (!SUPPORTED_COMMANDS.has(command)) {\n throw new Error(`Unknown editor command: \"${command}\"`);\n }\n\n const sel = doc.getSelection();\n if (!sel || sel.rangeCount === 0) return false;\n\n const node = sel.anchorNode;\n if (!node || !element.contains(node)) return false;\n\n switch (command) {\n case 'bold':\n return hasAncestor(node, 'STRONG') || hasAncestor(node, 'B');\n case 'italic':\n return hasAncestor(node, 'EM') || hasAncestor(node, 'I');\n case 'heading':\n return hasAncestor(node, 'H1') || hasAncestor(node, 'H2') || hasAncestor(node, 'H3');\n case 'blockquote':\n return hasAncestor(node, 'BLOCKQUOTE');\n case 'unorderedList':\n return hasAncestor(node, 'UL');\n case 'orderedList':\n return hasAncestor(node, 'OL');\n case 'link':\n return hasAncestor(node, 'A');\n case 'unlink':\n return false;\n case 'codeBlock':\n return hasAncestor(node, 'PRE');\n default:\n return false;\n }\n },\n\n getHTML(): string {\n return element.innerHTML;\n },\n\n getText(): string {\n return element.textContent ?? '';\n },\n\n destroy(): void {\n element.removeEventListener('keydown', onKeydown);\n element.removeEventListener('paste', onPaste);\n element.removeEventListener('input', onInput);\n enforcer.destroy();\n element.contentEditable = 'false';\n },\n\n on(event: string, handler: EventHandler): void {\n if (!handlers[event]) handlers[event] = [];\n handlers[event].push(handler);\n },\n };\n\n return editor;\n}\n", "import type { Editor, ToolbarOptions, Toolbar } from './types';\nimport { isProtocolAllowed } from './shared';\nimport { DEFAULT_POLICY } from './defaults';\n\nexport type { ToolbarOptions, Toolbar } from './types';\n\nconst ACTION_LABELS: Record<string, string> = {\n bold: 'Bold',\n italic: 'Italic',\n heading: 'Heading',\n blockquote: 'Blockquote',\n unorderedList: 'Bulleted list',\n orderedList: 'Numbered list',\n link: 'Link',\n unlink: 'Remove link',\n codeBlock: 'Code block',\n};\n\nconst DEFAULT_ACTIONS = [\n 'bold',\n 'italic',\n 'heading',\n 'unorderedList',\n 'orderedList',\n 'link',\n 'codeBlock',\n];\n\n/**\n * Create a toolbar that drives an Editor instance.\n *\n * Renders a `<div role=\"toolbar\">` with buttons for each action.\n * Supports ARIA roles, keyboard navigation (arrow keys between\n * buttons, Tab exits), and active-state tracking via selectionchange.\n */\nexport function createToolbar(\n editor: Editor,\n options?: ToolbarOptions,\n): Toolbar {\n const actions = options?.actions ?? DEFAULT_ACTIONS;\n const doc = document;\n\n // Container\n const container = options?.element ?? doc.createElement('div');\n container.setAttribute('role', 'toolbar');\n container.setAttribute('aria-label', 'Text formatting');\n container.classList.add('minisiwyg-toolbar');\n\n const buttons: HTMLButtonElement[] = [];\n\n for (const action of actions) {\n const btn = doc.createElement('button');\n btn.type = 'button';\n btn.className = `minisiwyg-btn minisiwyg-btn-${action}`;\n const label = ACTION_LABELS[action] ?? action;\n btn.setAttribute('aria-label', label);\n btn.setAttribute('aria-pressed', 'false');\n btn.textContent = label;\n\n // Only first button is in tab order; rest use arrow keys\n btn.tabIndex = buttons.length === 0 ? 0 : -1;\n\n btn.addEventListener('click', () => onButtonClick(action));\n\n container.appendChild(btn);\n buttons.push(btn);\n }\n\n // Caller is responsible for placing toolbar.element in the DOM\n\n function onButtonClick(action: string): void {\n try {\n if (action === 'link') {\n const url = window.prompt('Enter URL')?.trim();\n if (!url) return;\n if (!isProtocolAllowed(url, DEFAULT_POLICY.protocols)) return;\n editor.exec('link', url);\n } else {\n editor.exec(action);\n }\n } catch {\n // Unknown or invalid commands \u2014 don't crash the toolbar\n }\n updateActiveStates();\n }\n\n function updateActiveStates(): void {\n for (let i = 0; i < buttons.length; i++) {\n const action = actions[i];\n try {\n const active = editor.queryState(action);\n buttons[i].setAttribute('aria-pressed', String(active));\n buttons[i].classList.toggle('minisiwyg-btn-active', active);\n } catch {\n // queryState may throw for unknown commands; ignore\n }\n }\n }\n\n // Keyboard navigation within toolbar\n function onKeydown(e: KeyboardEvent): void {\n const target = e.target as HTMLElement;\n const idx = buttons.indexOf(target as HTMLButtonElement);\n if (idx === -1) return;\n\n let next = -1;\n if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n e.preventDefault();\n next = (idx + 1) % buttons.length;\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n e.preventDefault();\n next = (idx - 1 + buttons.length) % buttons.length;\n } else if (e.key === 'Home') {\n e.preventDefault();\n next = 0;\n } else if (e.key === 'End') {\n e.preventDefault();\n next = buttons.length - 1;\n }\n\n if (next >= 0) {\n buttons[idx].tabIndex = -1;\n buttons[next].tabIndex = 0;\n buttons[next].focus();\n }\n }\n\n container.addEventListener('keydown', onKeydown);\n\n // Track selection changes to update active states (debounced to one per frame)\n let rafId = 0;\n function onSelectionChange(): void {\n cancelAnimationFrame(rafId);\n rafId = requestAnimationFrame(updateActiveStates);\n }\n\n doc.addEventListener('selectionchange', onSelectionChange);\n\n // Return the container element for the caller to place in the DOM\n const toolbar: Toolbar = {\n element: container,\n destroy(): void {\n cancelAnimationFrame(rafId);\n container.removeEventListener('keydown', onKeydown);\n doc.removeEventListener('selectionchange', onSelectionChange);\n // Remove buttons\n for (const btn of buttons) {\n btn.remove();\n }\n // Remove container if we created it (not user-provided)\n if (!options?.element) {\n container.remove();\n }\n buttons.length = 0;\n },\n };\n\n return toolbar;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["policy", "attrs", "DEFAULT_POLICY", "TAG_NORMALIZE", "URL_ATTRS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "walkAndSanitize", "parent", "policy", "depth", "children", "node", "el", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "current", "replacement", "attrs", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "sanitizeToFragment", "html", "template", "fragment", "truncateToLength", "sanitize", "container", "maxLength", "remaining", "child", "text", "getDepth", "node", "root", "depth", "current", "enforceElement", "el", "policy", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "parent", "replacement", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "enforceSubtree", "children", "child", "createPolicyEnforcer", "element", "isApplyingFix", "errorHandlers", "emitError", "error", "handler", "observer", "mutations", "mutation", "target", "normalizedTag", "lowerAttr", "value", "err", "event", "SUPPORTED_COMMANDS", "createEditor", "element", "options", "src", "DEFAULT_POLICY", "policy", "k", "v", "handlers", "doc", "emit", "event", "args", "handler", "enforcer", "createPolicyEnforcer", "err", "onPaste", "e", "clipboard", "sel", "findAncestor", "text", "range", "textNode", "html", "fragment", "sanitizeToFragment", "selection", "pasteTextLen", "lastNode", "newRange", "onInput", "onKeydown", "anchor", "pre", "p", "node", "tagName", "current", "hasAncestor", "command", "value", "level", "trimmed", "isProtocolAllowed", "r", "block", "pre2", "code", "blockText", "ACTION_LABELS", "DEFAULT_ACTIONS", "createToolbar", "editor", "options", "actions", "doc", "container", "buttons", "action", "btn", "label", "onButtonClick", "url", "isProtocolAllowed", "DEFAULT_POLICY", "updateActiveStates", "i", "active", "onKeydown", "e", "target", "idx", "next", "rafId", "onSelectionChange"]
|
|
7
|
+
}
|
package/dist/policy.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var l=Object.defineProperty;var N=Object.getOwnPropertyDescriptor;var w=Object.getOwnPropertyNames;var L=Object.prototype.hasOwnProperty;var T=(e,t)=>{for(var o in t)l(e,o,{get:t[o],enumerable:!0})},x=(e,t,o,i)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of w(t))!L.call(e,n)&&n!==o&&l(e,n,{get:()=>t[n],enumerable:!(i=N(t,n))||i.enumerable});return e};var P=e=>x(l({},"__esModule",{value:!0}),e);var _={};T(_,{DEFAULT_POLICY:()=>b,createPolicyEnforcer:()=>R});module.exports=P(_);var m={b:"strong",i:"em"},p=new Set(["href","src","action","formaction"]),S=new Set(["javascript","data"]);function z(e){let t=e.trim();t=t.replace(/&#x([0-9a-f]+);?/gi,(i,n)=>String.fromCharCode(parseInt(n,16))),t=t.replace(/&#(\d+);?/g,(i,n)=>String.fromCharCode(parseInt(n,10)));try{t=decodeURIComponent(t)}catch{}t=t.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let o=t.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return o?o[1].toLowerCase():null}function h(e,t){let o=z(e);return o===null?!0:S.has(o)?!1:t.includes(o)}var f={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(f);Object.freeze(f.protocols);for(let e of Object.values(f.tags))Object.freeze(e);Object.freeze(f.tags);var b=f;function O(e,t){let o=0,i=e.parentNode;for(;i&&i!==t;)i.nodeType===1&&o++,i=i.parentNode;return o}function A(e,t,o){let i=e.tagName.toLowerCase(),n=m[i];if(n&&(i=n),O(e,o)>=t.maxDepth)return e.parentNode?.removeChild(e),!0;let c=t.tags[i];if(c===void 0){if(t.strip)e.parentNode?.removeChild(e);else{let r=e.parentNode;if(r){for(;e.firstChild;)r.insertBefore(e.firstChild,e);r.removeChild(e)}}return!0}let a=e;if(n&&e.tagName.toLowerCase()!==n){let r=e.ownerDocument.createElement(n);for(;e.firstChild;)r.appendChild(e.firstChild);for(let s of Array.from(e.attributes))r.setAttribute(s.name,s.value);e.parentNode?.replaceChild(r,e),a=r}for(let r of Array.from(a.attributes)){let s=r.name.toLowerCase();if(s.startsWith("on")){a.removeAttribute(r.name);continue}if(!c.includes(s)){a.removeAttribute(r.name);continue}p.has(s)&&(h(r.value,t.protocols)||a.removeAttribute(r.name))}return!1}function E(e,t,o){let i=Array.from(e.childNodes);for(let n of i){if(n.nodeType!==1){n.nodeType!==3&&e.removeChild(n);continue}A(n,t,o)||E(n,t,o)}}function R(e,t){if(!t||!t.tags)throw new TypeError('Policy must have a "tags" property');let o=!1,i=[];function n(c){for(let a of i)a(c)}let d=new MutationObserver(c=>{if(!o){o=!0;try{for(let a of c)if(a.type==="childList")for(let r of Array.from(a.addedNodes)){if(r.nodeType===3)continue;if(r.nodeType!==1){r.parentNode?.removeChild(r);continue}A(r,t,e)||E(r,t,e)}else if(a.type==="attributes"){let r=a.target;if(r.nodeType!==1)continue;let s=a.attributeName;if(!s)continue;let g=r.tagName.toLowerCase(),C=m[g]||g,v=t.tags[C];if(!v)continue;let u=s.toLowerCase();if(u.startsWith("on")){r.removeAttribute(s);continue}if(!v.includes(u)){r.removeAttribute(s);continue}if(p.has(u)){let y=r.getAttribute(s);y&&!h(y,t.protocols)&&r.removeAttribute(s)}}}catch(a){n(a instanceof Error?a:new Error(String(a)))}finally{o=!1}}});return d.observe(e,{childList:!0,attributes:!0,subtree:!0}),{destroy(){d.disconnect()},on(c,a){c==="error"&&i.push(a)}}}
|
|
2
|
+
//# sourceMappingURL=policy.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/policy.ts", "../src/shared.ts", "../src/defaults.ts"],
|
|
4
|
+
"sourcesContent": ["import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\n\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\nexport interface PolicyEnforcer {\n destroy(): void;\n on(event: 'error', handler: (error: Error) => void): void;\n}\n\n/**\n * Get the nesting depth of a node within a root element.\n */\nfunction getDepth(node: Node, root: Node): number {\n let depth = 0;\n let current = node.parentNode;\n while (current && current !== root) {\n if (current.nodeType === 1) depth++;\n current = current.parentNode;\n }\n return depth;\n}\n\n/**\n * Check if an element is allowed by the policy and fix it if not.\n * Returns true if the node was removed/replaced.\n */\nfunction enforceElement(\n el: Element,\n policy: SanitizePolicy,\n root: HTMLElement,\n): boolean {\n let tagName = el.tagName.toLowerCase();\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) tagName = normalized;\n\n // Check depth\n const depth = getDepth(el, root);\n if (depth >= policy.maxDepth) {\n el.parentNode?.removeChild(el);\n return true;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n if (policy.strip) {\n el.parentNode?.removeChild(el);\n } else {\n // Unwrap: move children up, then remove the element\n const parent = el.parentNode;\n if (parent) {\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n }\n return true;\n }\n\n // Normalize tag if needed (e.g. <b> \u2192 <strong>)\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const replacement = el.ownerDocument.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n // Copy allowed attributes\n for (const attr of Array.from(el.attributes)) {\n replacement.setAttribute(attr.name, attr.value);\n }\n el.parentNode?.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n for (const attr of Array.from(current.attributes)) {\n const attrName = attr.name.toLowerCase();\n\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n return false;\n}\n\n/**\n * Recursively enforce policy on all descendants of a node.\n */\nfunction enforceSubtree(node: Node, policy: SanitizePolicy, root: HTMLElement): void {\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (child.nodeType !== 1) {\n // Remove non-text, non-element nodes (comments, etc.)\n if (child.nodeType !== 3) {\n node.removeChild(child);\n }\n continue;\n }\n const removed = enforceElement(child as Element, policy, root);\n if (!removed) {\n enforceSubtree(child, policy, root);\n }\n }\n}\n\n/**\n * Create a policy enforcer that uses MutationObserver to enforce\n * the sanitization policy on a live DOM element.\n *\n * This is defense-in-depth \u2014 the paste handler is the primary security boundary.\n * The observer catches mutations from execCommand, programmatic DOM manipulation,\n * and other sources.\n */\nexport function createPolicyEnforcer(\n element: HTMLElement,\n policy: SanitizePolicy,\n): PolicyEnforcer {\n if (!policy || !policy.tags) {\n throw new TypeError('Policy must have a \"tags\" property');\n }\n\n let isApplyingFix = false;\n const errorHandlers: Array<(error: Error) => void> = [];\n\n function emitError(error: Error): void {\n for (const handler of errorHandlers) {\n handler(error);\n }\n }\n\n const observer = new MutationObserver((mutations) => {\n if (isApplyingFix) return;\n isApplyingFix = true;\n\n try {\n for (const mutation of mutations) {\n if (mutation.type === 'childList') {\n for (const node of Array.from(mutation.addedNodes)) {\n // Skip text nodes\n if (node.nodeType === 3) continue;\n\n // Remove non-element nodes\n if (node.nodeType !== 1) {\n node.parentNode?.removeChild(node);\n continue;\n }\n\n const removed = enforceElement(node as Element, policy, element);\n if (!removed) {\n // Also enforce on all descendants of the added node\n enforceSubtree(node, policy, element);\n }\n }\n } else if (mutation.type === 'attributes') {\n const target = mutation.target as Element;\n if (target.nodeType !== 1) continue;\n\n const attrName = mutation.attributeName;\n if (!attrName) continue;\n\n const tagName = target.tagName.toLowerCase();\n const normalizedTag = TAG_NORMALIZE[tagName] || tagName;\n const allowedAttrs = policy.tags[normalizedTag];\n\n if (!allowedAttrs) continue;\n\n const lowerAttr = attrName.toLowerCase();\n\n if (lowerAttr.startsWith('on')) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (!allowedAttrs.includes(lowerAttr)) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (URL_ATTRS.has(lowerAttr)) {\n const value = target.getAttribute(attrName);\n if (value && !isProtocolAllowed(value, policy.protocols)) {\n target.removeAttribute(attrName);\n }\n }\n }\n }\n } catch (err) {\n emitError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n isApplyingFix = false;\n }\n });\n\n observer.observe(element, {\n childList: true,\n attributes: true,\n subtree: true,\n });\n\n return {\n destroy() {\n observer.disconnect();\n },\n on(event: 'error', handler: (error: Error) => void) {\n if (event === 'error') {\n errorHandlers.push(handler);\n }\n },\n };\n}\n", "/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["policy_exports", "__export", "DEFAULT_POLICY", "createPolicyEnforcer", "__toCommonJS", "TAG_NORMALIZE", "URL_ATTRS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "getDepth", "node", "root", "depth", "current", "enforceElement", "el", "policy", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "parent", "replacement", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "enforceSubtree", "children", "child", "createPolicyEnforcer", "element", "isApplyingFix", "errorHandlers", "emitError", "error", "handler", "observer", "mutations", "mutation", "target", "normalizedTag", "lowerAttr", "value", "err", "event"]
|
|
7
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SanitizePolicy } from './types';
|
|
2
|
+
export { DEFAULT_POLICY } from './defaults';
|
|
3
|
+
export type { SanitizePolicy } from './types';
|
|
4
|
+
export interface PolicyEnforcer {
|
|
5
|
+
destroy(): void;
|
|
6
|
+
on(event: 'error', handler: (error: Error) => void): void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Create a policy enforcer that uses MutationObserver to enforce
|
|
10
|
+
* the sanitization policy on a live DOM element.
|
|
11
|
+
*
|
|
12
|
+
* This is defense-in-depth — the paste handler is the primary security boundary.
|
|
13
|
+
* The observer catches mutations from execCommand, programmatic DOM manipulation,
|
|
14
|
+
* and other sources.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createPolicyEnforcer(element: HTMLElement, policy: SanitizePolicy): PolicyEnforcer;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var l={b:"strong",i:"em"},m=new Set(["href","src","action","formaction"]),E=new Set(["javascript","data"]);function C(e){let t=e.trim();t=t.replace(/&#x([0-9a-f]+);?/gi,(a,i)=>String.fromCharCode(parseInt(i,16))),t=t.replace(/&#(\d+);?/g,(a,i)=>String.fromCharCode(parseInt(i,10)));try{t=decodeURIComponent(t)}catch{}t=t.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let n=t.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return n?n[1].toLowerCase():null}function p(e,t){let n=C(e);return n===null?!0:E.has(n)?!1:t.includes(n)}var f={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(f);Object.freeze(f.protocols);for(let e of Object.values(f.tags))Object.freeze(e);Object.freeze(f.tags);var N=f;function w(e,t){let n=0,a=e.parentNode;for(;a&&a!==t;)a.nodeType===1&&n++,a=a.parentNode;return n}function y(e,t,n){let a=e.tagName.toLowerCase(),i=l[a];if(i&&(a=i),w(e,n)>=t.maxDepth)return e.parentNode?.removeChild(e),!0;let c=t.tags[a];if(c===void 0){if(t.strip)e.parentNode?.removeChild(e);else{let r=e.parentNode;if(r){for(;e.firstChild;)r.insertBefore(e.firstChild,e);r.removeChild(e)}}return!0}let o=e;if(i&&e.tagName.toLowerCase()!==i){let r=e.ownerDocument.createElement(i);for(;e.firstChild;)r.appendChild(e.firstChild);for(let s of Array.from(e.attributes))r.setAttribute(s.name,s.value);e.parentNode?.replaceChild(r,e),o=r}for(let r of Array.from(o.attributes)){let s=r.name.toLowerCase();if(s.startsWith("on")){o.removeAttribute(r.name);continue}if(!c.includes(s)){o.removeAttribute(r.name);continue}m.has(s)&&(p(r.value,t.protocols)||o.removeAttribute(r.name))}return!1}function b(e,t,n){let a=Array.from(e.childNodes);for(let i of a){if(i.nodeType!==1){i.nodeType!==3&&e.removeChild(i);continue}y(i,t,n)||b(i,t,n)}}function P(e,t){if(!t||!t.tags)throw new TypeError('Policy must have a "tags" property');let n=!1,a=[];function i(c){for(let o of a)o(c)}let d=new MutationObserver(c=>{if(!n){n=!0;try{for(let o of c)if(o.type==="childList")for(let r of Array.from(o.addedNodes)){if(r.nodeType===3)continue;if(r.nodeType!==1){r.parentNode?.removeChild(r);continue}y(r,t,e)||b(r,t,e)}else if(o.type==="attributes"){let r=o.target;if(r.nodeType!==1)continue;let s=o.attributeName;if(!s)continue;let h=r.tagName.toLowerCase(),A=l[h]||h,g=t.tags[A];if(!g)continue;let u=s.toLowerCase();if(u.startsWith("on")){r.removeAttribute(s);continue}if(!g.includes(u)){r.removeAttribute(s);continue}if(m.has(u)){let v=r.getAttribute(s);v&&!p(v,t.protocols)&&r.removeAttribute(s)}}}catch(o){i(o instanceof Error?o:new Error(String(o)))}finally{n=!1}}});return d.observe(e,{childList:!0,attributes:!0,subtree:!0}),{destroy(){d.disconnect()},on(c,o){c==="error"&&a.push(o)}}}export{N as DEFAULT_POLICY,P as createPolicyEnforcer};
|
|
2
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/shared.ts", "../src/defaults.ts", "../src/policy.ts"],
|
|
4
|
+
"sourcesContent": ["/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n", "import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\n\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\nexport interface PolicyEnforcer {\n destroy(): void;\n on(event: 'error', handler: (error: Error) => void): void;\n}\n\n/**\n * Get the nesting depth of a node within a root element.\n */\nfunction getDepth(node: Node, root: Node): number {\n let depth = 0;\n let current = node.parentNode;\n while (current && current !== root) {\n if (current.nodeType === 1) depth++;\n current = current.parentNode;\n }\n return depth;\n}\n\n/**\n * Check if an element is allowed by the policy and fix it if not.\n * Returns true if the node was removed/replaced.\n */\nfunction enforceElement(\n el: Element,\n policy: SanitizePolicy,\n root: HTMLElement,\n): boolean {\n let tagName = el.tagName.toLowerCase();\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) tagName = normalized;\n\n // Check depth\n const depth = getDepth(el, root);\n if (depth >= policy.maxDepth) {\n el.parentNode?.removeChild(el);\n return true;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n if (policy.strip) {\n el.parentNode?.removeChild(el);\n } else {\n // Unwrap: move children up, then remove the element\n const parent = el.parentNode;\n if (parent) {\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n }\n return true;\n }\n\n // Normalize tag if needed (e.g. <b> \u2192 <strong>)\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const replacement = el.ownerDocument.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n // Copy allowed attributes\n for (const attr of Array.from(el.attributes)) {\n replacement.setAttribute(attr.name, attr.value);\n }\n el.parentNode?.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n for (const attr of Array.from(current.attributes)) {\n const attrName = attr.name.toLowerCase();\n\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n return false;\n}\n\n/**\n * Recursively enforce policy on all descendants of a node.\n */\nfunction enforceSubtree(node: Node, policy: SanitizePolicy, root: HTMLElement): void {\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (child.nodeType !== 1) {\n // Remove non-text, non-element nodes (comments, etc.)\n if (child.nodeType !== 3) {\n node.removeChild(child);\n }\n continue;\n }\n const removed = enforceElement(child as Element, policy, root);\n if (!removed) {\n enforceSubtree(child, policy, root);\n }\n }\n}\n\n/**\n * Create a policy enforcer that uses MutationObserver to enforce\n * the sanitization policy on a live DOM element.\n *\n * This is defense-in-depth \u2014 the paste handler is the primary security boundary.\n * The observer catches mutations from execCommand, programmatic DOM manipulation,\n * and other sources.\n */\nexport function createPolicyEnforcer(\n element: HTMLElement,\n policy: SanitizePolicy,\n): PolicyEnforcer {\n if (!policy || !policy.tags) {\n throw new TypeError('Policy must have a \"tags\" property');\n }\n\n let isApplyingFix = false;\n const errorHandlers: Array<(error: Error) => void> = [];\n\n function emitError(error: Error): void {\n for (const handler of errorHandlers) {\n handler(error);\n }\n }\n\n const observer = new MutationObserver((mutations) => {\n if (isApplyingFix) return;\n isApplyingFix = true;\n\n try {\n for (const mutation of mutations) {\n if (mutation.type === 'childList') {\n for (const node of Array.from(mutation.addedNodes)) {\n // Skip text nodes\n if (node.nodeType === 3) continue;\n\n // Remove non-element nodes\n if (node.nodeType !== 1) {\n node.parentNode?.removeChild(node);\n continue;\n }\n\n const removed = enforceElement(node as Element, policy, element);\n if (!removed) {\n // Also enforce on all descendants of the added node\n enforceSubtree(node, policy, element);\n }\n }\n } else if (mutation.type === 'attributes') {\n const target = mutation.target as Element;\n if (target.nodeType !== 1) continue;\n\n const attrName = mutation.attributeName;\n if (!attrName) continue;\n\n const tagName = target.tagName.toLowerCase();\n const normalizedTag = TAG_NORMALIZE[tagName] || tagName;\n const allowedAttrs = policy.tags[normalizedTag];\n\n if (!allowedAttrs) continue;\n\n const lowerAttr = attrName.toLowerCase();\n\n if (lowerAttr.startsWith('on')) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (!allowedAttrs.includes(lowerAttr)) {\n target.removeAttribute(attrName);\n continue;\n }\n\n if (URL_ATTRS.has(lowerAttr)) {\n const value = target.getAttribute(attrName);\n if (value && !isProtocolAllowed(value, policy.protocols)) {\n target.removeAttribute(attrName);\n }\n }\n }\n }\n } catch (err) {\n emitError(err instanceof Error ? err : new Error(String(err)));\n } finally {\n isApplyingFix = false;\n }\n });\n\n observer.observe(element, {\n childList: true,\n attributes: true,\n subtree: true,\n });\n\n return {\n destroy() {\n observer.disconnect();\n },\n on(event: 'error', handler: (error: Error) => void) {\n if (event === 'error') {\n errorHandlers.push(handler);\n }\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["TAG_NORMALIZE", "URL_ATTRS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "getDepth", "node", "root", "depth", "current", "enforceElement", "el", "policy", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "parent", "replacement", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "enforceSubtree", "children", "child", "createPolicyEnforcer", "element", "isApplyingFix", "errorHandlers", "emitError", "error", "handler", "observer", "mutations", "mutation", "target", "normalizedTag", "lowerAttr", "value", "err", "event"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var u=Object.defineProperty;var T=Object.getOwnPropertyDescriptor;var b=Object.getOwnPropertyNames;var y=Object.prototype.hasOwnProperty;var S=(t,e)=>{for(var o in e)u(t,o,{get:e[o],enumerable:!0})},v=(t,e,o,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of b(e))!y.call(t,n)&&n!==o&&u(t,n,{get:()=>e[n],enumerable:!(i=T(e,n))||i.enumerable});return t};var P=t=>v(u({},"__esModule",{value:!0}),t);var N={};S(N,{DEFAULT_POLICY:()=>x,sanitize:()=>O,sanitizeToFragment:()=>L});module.exports=P(N);var g={b:"strong",i:"em"},p=new Set(["href","src","action","formaction"]),w=new Set(["javascript","data"]);function E(t){let e=t.trim();e=e.replace(/&#x([0-9a-f]+);?/gi,(i,n)=>String.fromCharCode(parseInt(n,16))),e=e.replace(/&#(\d+);?/g,(i,n)=>String.fromCharCode(parseInt(n,10)));try{e=decodeURIComponent(e)}catch{}e=e.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let o=e.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return o?o[1].toLowerCase():null}function C(t,e){let o=E(t);return o===null?!0:w.has(o)?!1:e.includes(o)}var m={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(m);Object.freeze(m.protocols);for(let t of Object.values(m.tags))Object.freeze(t);Object.freeze(m.tags);var x=m;function d(t,e,o){let i=Array.from(t.childNodes);for(let n of i){if(n.nodeType===3)continue;if(n.nodeType!==1){t.removeChild(n);continue}let r=n,f=r.tagName.toLowerCase(),l=g[f];if(l&&(f=l),o>=e.maxDepth){t.removeChild(r);continue}let h=e.tags[f];if(h===void 0){if(e.strip)t.removeChild(r);else{for(d(r,e,o);r.firstChild;)t.insertBefore(r.firstChild,r);t.removeChild(r)}continue}let a=r;if(l&&r.tagName.toLowerCase()!==l){let c=r.ownerDocument.createElement(l);for(;r.firstChild;)c.appendChild(r.firstChild);t.replaceChild(c,r),a=c}let z=Array.from(a.attributes);for(let s of z){let c=s.name.toLowerCase();if(c.startsWith("on")){a.removeAttribute(s.name);continue}if(!h.includes(c)){a.removeAttribute(s.name);continue}p.has(c)&&(C(s.value,e.protocols)||a.removeAttribute(s.name))}d(a,e,o+1)}}function L(t,e){let o=document.createElement("template");if(!t)return o.content;o.innerHTML=t;let i=o.content;return d(i,e,0),e.maxLength>0&&(i.textContent?.length??0)>e.maxLength&&A(i,e.maxLength),i}function O(t,e){if(!t)return"";let o=L(t,e),i=document.createElement("div");return i.appendChild(o),i.innerHTML}function A(t,e){let o=e,i=Array.from(t.childNodes);for(let n of i){if(o<=0){t.removeChild(n);continue}if(n.nodeType===3){let r=n.textContent??"";r.length>o?(n.textContent=r.slice(0,o),o=0):o-=r.length}else n.nodeType===1?o=A(n,o):t.removeChild(n)}return o}
|
|
2
|
+
//# sourceMappingURL=sanitize.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/sanitize.ts", "../src/shared.ts", "../src/defaults.ts"],
|
|
4
|
+
"sourcesContent": ["import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\n/**\n * Walk a DOM tree depth-first and sanitize according to policy.\n * Mutates the tree in place.\n */\nfunction walkAndSanitize(\n parent: Node,\n policy: SanitizePolicy,\n depth: number,\n): void {\n const children = Array.from(parent.childNodes);\n\n for (const node of children) {\n // Text nodes: always allowed (length enforcement happens after walk)\n if (node.nodeType === 3) continue;\n\n // Non-element, non-text nodes (comments, processing instructions): remove\n if (node.nodeType !== 1) {\n parent.removeChild(node);\n continue;\n }\n\n const el = node as Element;\n let tagName = el.tagName.toLowerCase();\n\n // Normalize tags (b\u2192strong, i\u2192em)\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) {\n tagName = normalized;\n }\n\n // Check depth limit\n if (depth >= policy.maxDepth) {\n parent.removeChild(el);\n continue;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n // Tag not allowed\n if (policy.strip) {\n // Remove the node and all its children\n parent.removeChild(el);\n } else {\n // Unwrap: sanitize children first, then move them up\n walkAndSanitize(el, policy, depth);\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n continue;\n }\n\n // Tag is allowed. If it was normalized, replace with correct element.\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const doc = el.ownerDocument!;\n const replacement = doc.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n parent.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n const attrs = Array.from(current.attributes);\n for (const attr of attrs) {\n const attrName = attr.name.toLowerCase();\n\n // Always strip event handlers (on*)\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Check attribute whitelist\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Validate URL protocols on URL-bearing attributes\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n // Recurse into children\n walkAndSanitize(current, policy, depth + 1);\n }\n}\n\n/**\n * Sanitize an HTML string and return a DocumentFragment.\n * Avoids the serialize\u2192reparse round-trip that can cause mXSS.\n */\nexport function sanitizeToFragment(html: string, policy: SanitizePolicy): DocumentFragment {\n const template = document.createElement('template');\n if (!html) return template.content;\n\n template.innerHTML = html;\n const fragment = template.content;\n\n walkAndSanitize(fragment, policy, 0);\n\n if (policy.maxLength > 0 && (fragment.textContent?.length ?? 0) > policy.maxLength) {\n truncateToLength(fragment, policy.maxLength);\n }\n\n return fragment;\n}\n\n/**\n * Sanitize an HTML string according to the given policy.\n *\n * Uses a <template> element to parse HTML without executing scripts.\n * Walks the resulting DOM tree depth-first, removing disallowed elements\n * and attributes. Returns the sanitized HTML string.\n */\nexport function sanitize(html: string, policy: SanitizePolicy): string {\n if (!html) return '';\n\n const fragment = sanitizeToFragment(html, policy);\n const container = document.createElement('div');\n container.appendChild(fragment);\n return container.innerHTML;\n}\n\n/**\n * Truncate a DOM tree's text content to a maximum length.\n * Removes nodes beyond the limit while preserving structure.\n */\nfunction truncateToLength(node: Node, maxLength: number): number {\n let remaining = maxLength;\n\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (remaining <= 0) {\n node.removeChild(child);\n continue;\n }\n\n if (child.nodeType === 3) {\n // Text node\n const text = child.textContent ?? '';\n if (text.length > remaining) {\n child.textContent = text.slice(0, remaining);\n remaining = 0;\n } else {\n remaining -= text.length;\n }\n } else if (child.nodeType === 1) {\n remaining = truncateToLength(child, remaining);\n } else {\n node.removeChild(child);\n }\n }\n\n return remaining;\n}\n", "/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["sanitize_exports", "__export", "DEFAULT_POLICY", "sanitize", "sanitizeToFragment", "__toCommonJS", "TAG_NORMALIZE", "URL_ATTRS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "walkAndSanitize", "parent", "policy", "depth", "children", "node", "el", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "current", "replacement", "attrs", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "sanitizeToFragment", "html", "template", "fragment", "truncateToLength", "sanitize", "container", "maxLength", "remaining", "child", "text"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SanitizePolicy } from './types';
|
|
2
|
+
export { DEFAULT_POLICY } from './defaults';
|
|
3
|
+
export type { SanitizePolicy } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize an HTML string and return a DocumentFragment.
|
|
6
|
+
* Avoids the serialize→reparse round-trip that can cause mXSS.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sanitizeToFragment(html: string, policy: SanitizePolicy): DocumentFragment;
|
|
9
|
+
/**
|
|
10
|
+
* Sanitize an HTML string according to the given policy.
|
|
11
|
+
*
|
|
12
|
+
* Uses a <template> element to parse HTML without executing scripts.
|
|
13
|
+
* Walks the resulting DOM tree depth-first, removing disallowed elements
|
|
14
|
+
* and attributes. Returns the sanitized HTML string.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sanitize(html: string, policy: SanitizePolicy): string;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var h={b:"strong",i:"em"},g=new Set(["href","src","action","formaction"]),L=new Set(["javascript","data"]);function A(o){let e=o.trim();e=e.replace(/&#x([0-9a-f]+);?/gi,(i,r)=>String.fromCharCode(parseInt(r,16))),e=e.replace(/&#(\d+);?/g,(i,r)=>String.fromCharCode(parseInt(r,10)));try{e=decodeURIComponent(e)}catch{}e=e.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let t=e.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return t?t[1].toLowerCase():null}function p(o,e){let t=A(o);return t===null?!0:L.has(t)?!1:e.includes(t)}var m={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(m);Object.freeze(m.protocols);for(let o of Object.values(m.tags))Object.freeze(o);Object.freeze(m.tags);var z=m;function u(o,e,t){let i=Array.from(o.childNodes);for(let r of i){if(r.nodeType===3)continue;if(r.nodeType!==1){o.removeChild(r);continue}let n=r,f=n.tagName.toLowerCase(),l=h[f];if(l&&(f=l),t>=e.maxDepth){o.removeChild(n);continue}let d=e.tags[f];if(d===void 0){if(e.strip)o.removeChild(n);else{for(u(n,e,t);n.firstChild;)o.insertBefore(n.firstChild,n);o.removeChild(n)}continue}let a=n;if(l&&n.tagName.toLowerCase()!==l){let c=n.ownerDocument.createElement(l);for(;n.firstChild;)c.appendChild(n.firstChild);o.replaceChild(c,n),a=c}let x=Array.from(a.attributes);for(let s of x){let c=s.name.toLowerCase();if(c.startsWith("on")){a.removeAttribute(s.name);continue}if(!d.includes(c)){a.removeAttribute(s.name);continue}g.has(c)&&(p(s.value,e.protocols)||a.removeAttribute(s.name))}u(a,e,t+1)}}function T(o,e){let t=document.createElement("template");if(!o)return t.content;t.innerHTML=o;let i=t.content;return u(i,e,0),e.maxLength>0&&(i.textContent?.length??0)>e.maxLength&&C(i,e.maxLength),i}function v(o,e){if(!o)return"";let t=T(o,e),i=document.createElement("div");return i.appendChild(t),i.innerHTML}function C(o,e){let t=e,i=Array.from(o.childNodes);for(let r of i){if(t<=0){o.removeChild(r);continue}if(r.nodeType===3){let n=r.textContent??"";n.length>t?(r.textContent=n.slice(0,t),t=0):t-=n.length}else r.nodeType===1?t=C(r,t):o.removeChild(r)}return t}export{z as DEFAULT_POLICY,v as sanitize,T as sanitizeToFragment};
|
|
2
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/shared.ts", "../src/defaults.ts", "../src/sanitize.ts"],
|
|
4
|
+
"sourcesContent": ["/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n", "import type { SanitizePolicy } from './types';\nimport { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';\nexport { DEFAULT_POLICY } from './defaults';\nexport type { SanitizePolicy } from './types';\n\n/**\n * Walk a DOM tree depth-first and sanitize according to policy.\n * Mutates the tree in place.\n */\nfunction walkAndSanitize(\n parent: Node,\n policy: SanitizePolicy,\n depth: number,\n): void {\n const children = Array.from(parent.childNodes);\n\n for (const node of children) {\n // Text nodes: always allowed (length enforcement happens after walk)\n if (node.nodeType === 3) continue;\n\n // Non-element, non-text nodes (comments, processing instructions): remove\n if (node.nodeType !== 1) {\n parent.removeChild(node);\n continue;\n }\n\n const el = node as Element;\n let tagName = el.tagName.toLowerCase();\n\n // Normalize tags (b\u2192strong, i\u2192em)\n const normalized = TAG_NORMALIZE[tagName];\n if (normalized) {\n tagName = normalized;\n }\n\n // Check depth limit\n if (depth >= policy.maxDepth) {\n parent.removeChild(el);\n continue;\n }\n\n // Check tag whitelist\n const allowedAttrs = policy.tags[tagName];\n if (allowedAttrs === undefined) {\n // Tag not allowed\n if (policy.strip) {\n // Remove the node and all its children\n parent.removeChild(el);\n } else {\n // Unwrap: sanitize children first, then move them up\n walkAndSanitize(el, policy, depth);\n while (el.firstChild) {\n parent.insertBefore(el.firstChild, el);\n }\n parent.removeChild(el);\n }\n continue;\n }\n\n // Tag is allowed. If it was normalized, replace with correct element.\n let current: Element = el;\n if (normalized && el.tagName.toLowerCase() !== normalized) {\n const doc = el.ownerDocument!;\n const replacement = doc.createElement(normalized);\n while (el.firstChild) {\n replacement.appendChild(el.firstChild);\n }\n parent.replaceChild(replacement, el);\n current = replacement;\n }\n\n // Strip disallowed attributes\n const attrs = Array.from(current.attributes);\n for (const attr of attrs) {\n const attrName = attr.name.toLowerCase();\n\n // Always strip event handlers (on*)\n if (attrName.startsWith('on')) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Check attribute whitelist\n if (!allowedAttrs.includes(attrName)) {\n current.removeAttribute(attr.name);\n continue;\n }\n\n // Validate URL protocols on URL-bearing attributes\n if (URL_ATTRS.has(attrName)) {\n if (!isProtocolAllowed(attr.value, policy.protocols)) {\n current.removeAttribute(attr.name);\n }\n }\n }\n\n // Recurse into children\n walkAndSanitize(current, policy, depth + 1);\n }\n}\n\n/**\n * Sanitize an HTML string and return a DocumentFragment.\n * Avoids the serialize\u2192reparse round-trip that can cause mXSS.\n */\nexport function sanitizeToFragment(html: string, policy: SanitizePolicy): DocumentFragment {\n const template = document.createElement('template');\n if (!html) return template.content;\n\n template.innerHTML = html;\n const fragment = template.content;\n\n walkAndSanitize(fragment, policy, 0);\n\n if (policy.maxLength > 0 && (fragment.textContent?.length ?? 0) > policy.maxLength) {\n truncateToLength(fragment, policy.maxLength);\n }\n\n return fragment;\n}\n\n/**\n * Sanitize an HTML string according to the given policy.\n *\n * Uses a <template> element to parse HTML without executing scripts.\n * Walks the resulting DOM tree depth-first, removing disallowed elements\n * and attributes. Returns the sanitized HTML string.\n */\nexport function sanitize(html: string, policy: SanitizePolicy): string {\n if (!html) return '';\n\n const fragment = sanitizeToFragment(html, policy);\n const container = document.createElement('div');\n container.appendChild(fragment);\n return container.innerHTML;\n}\n\n/**\n * Truncate a DOM tree's text content to a maximum length.\n * Removes nodes beyond the limit while preserving structure.\n */\nfunction truncateToLength(node: Node, maxLength: number): number {\n let remaining = maxLength;\n\n const children = Array.from(node.childNodes);\n for (const child of children) {\n if (remaining <= 0) {\n node.removeChild(child);\n continue;\n }\n\n if (child.nodeType === 3) {\n // Text node\n const text = child.textContent ?? '';\n if (text.length > remaining) {\n child.textContent = text.slice(0, remaining);\n remaining = 0;\n } else {\n remaining -= text.length;\n }\n } else if (child.nodeType === 1) {\n remaining = truncateToLength(child, remaining);\n } else {\n node.removeChild(child);\n }\n }\n\n return remaining;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["TAG_NORMALIZE", "URL_ATTRS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "walkAndSanitize", "parent", "policy", "depth", "children", "node", "el", "tagName", "normalized", "TAG_NORMALIZE", "allowedAttrs", "current", "replacement", "attrs", "attr", "attrName", "URL_ATTRS", "isProtocolAllowed", "sanitizeToFragment", "html", "template", "fragment", "truncateToLength", "sanitize", "container", "maxLength", "remaining", "child", "text"]
|
|
7
|
+
}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Tag normalization map: browser-variant tags → semantic equivalents. */
|
|
2
|
+
export declare const TAG_NORMALIZE: Record<string, string>;
|
|
3
|
+
/** Attributes that contain URLs and need protocol validation. */
|
|
4
|
+
export declare const URL_ATTRS: Set<string>;
|
|
5
|
+
/** Protocols that are always denied regardless of policy. */
|
|
6
|
+
export declare const DENIED_PROTOCOLS: Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a URL-like string and extract the protocol.
|
|
9
|
+
* Returns the lowercase protocol name (without colon), or null if none found.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractProtocol(value: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Check if a URL value is allowed by the given protocol list.
|
|
14
|
+
* javascript: and data: are always denied.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean;
|
package/dist/toolbar.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var f=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var v=Object.getOwnPropertyNames;var A=Object.prototype.hasOwnProperty;var x=(n,e)=>{for(var a in e)f(n,a,{get:e[a],enumerable:!0})},k=(n,e,a,c)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of v(e))!A.call(n,o)&&o!==a&&f(n,o,{get:()=>e[o],enumerable:!(c=L(e,o))||c.enumerable});return n};var E=n=>k(f({},"__esModule",{value:!0}),n);var I={};x(I,{createToolbar:()=>S});module.exports=E(I);var T=new Set(["javascript","data"]);function w(n){let e=n.trim();e=e.replace(/&#x([0-9a-f]+);?/gi,(c,o)=>String.fromCharCode(parseInt(o,16))),e=e.replace(/&#(\d+);?/g,(c,o)=>String.fromCharCode(parseInt(o,10)));try{e=decodeURIComponent(e)}catch{}e=e.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let a=e.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return a?a[1].toLowerCase():null}function g(n,e){let a=w(n);return a===null?!0:T.has(a)?!1:e.includes(a)}var d={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(d);Object.freeze(d.protocols);for(let n of Object.values(d.tags))Object.freeze(n);Object.freeze(d.tags);var h=d;var O={bold:"Bold",italic:"Italic",heading:"Heading",blockquote:"Blockquote",unorderedList:"Bulleted list",orderedList:"Numbered list",link:"Link",unlink:"Remove link",codeBlock:"Code block"},C=["bold","italic","heading","unorderedList","orderedList","link","codeBlock"];function S(n,e){let a=e?.actions??C,c=document,o=e?.element??c.createElement("div");o.setAttribute("role","toolbar"),o.setAttribute("aria-label","Text formatting"),o.classList.add("minisiwyg-toolbar");let i=[];for(let t of a){let r=c.createElement("button");r.type="button",r.className=`minisiwyg-btn minisiwyg-btn-${t}`;let l=O[t]??t;r.setAttribute("aria-label",l),r.setAttribute("aria-pressed","false"),r.textContent=l,r.tabIndex=i.length===0?0:-1,r.addEventListener("click",()=>y(t)),o.appendChild(r),i.push(r)}function y(t){try{if(t==="link"){let r=window.prompt("Enter URL")?.trim();if(!r||!g(r,h.protocols))return;n.exec("link",r)}else n.exec(t)}catch{}m()}function m(){for(let t=0;t<i.length;t++){let r=a[t];try{let l=n.queryState(r);i[t].setAttribute("aria-pressed",String(l)),i[t].classList.toggle("minisiwyg-btn-active",l)}catch{}}}function p(t){let r=t.target,l=i.indexOf(r);if(l===-1)return;let s=-1;t.key==="ArrowRight"||t.key==="ArrowDown"?(t.preventDefault(),s=(l+1)%i.length):t.key==="ArrowLeft"||t.key==="ArrowUp"?(t.preventDefault(),s=(l-1+i.length)%i.length):t.key==="Home"?(t.preventDefault(),s=0):t.key==="End"&&(t.preventDefault(),s=i.length-1),s>=0&&(i[l].tabIndex=-1,i[s].tabIndex=0,i[s].focus())}o.addEventListener("keydown",p);let u=0;function b(){cancelAnimationFrame(u),u=requestAnimationFrame(m)}return c.addEventListener("selectionchange",b),{element:o,destroy(){cancelAnimationFrame(u),o.removeEventListener("keydown",p),c.removeEventListener("selectionchange",b);for(let t of i)t.remove();e?.element||o.remove(),i.length=0}}}
|
|
2
|
+
//# sourceMappingURL=toolbar.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/toolbar.ts", "../src/shared.ts", "../src/defaults.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Editor, ToolbarOptions, Toolbar } from './types';\nimport { isProtocolAllowed } from './shared';\nimport { DEFAULT_POLICY } from './defaults';\n\nexport type { ToolbarOptions, Toolbar } from './types';\n\nconst ACTION_LABELS: Record<string, string> = {\n bold: 'Bold',\n italic: 'Italic',\n heading: 'Heading',\n blockquote: 'Blockquote',\n unorderedList: 'Bulleted list',\n orderedList: 'Numbered list',\n link: 'Link',\n unlink: 'Remove link',\n codeBlock: 'Code block',\n};\n\nconst DEFAULT_ACTIONS = [\n 'bold',\n 'italic',\n 'heading',\n 'unorderedList',\n 'orderedList',\n 'link',\n 'codeBlock',\n];\n\n/**\n * Create a toolbar that drives an Editor instance.\n *\n * Renders a `<div role=\"toolbar\">` with buttons for each action.\n * Supports ARIA roles, keyboard navigation (arrow keys between\n * buttons, Tab exits), and active-state tracking via selectionchange.\n */\nexport function createToolbar(\n editor: Editor,\n options?: ToolbarOptions,\n): Toolbar {\n const actions = options?.actions ?? DEFAULT_ACTIONS;\n const doc = document;\n\n // Container\n const container = options?.element ?? doc.createElement('div');\n container.setAttribute('role', 'toolbar');\n container.setAttribute('aria-label', 'Text formatting');\n container.classList.add('minisiwyg-toolbar');\n\n const buttons: HTMLButtonElement[] = [];\n\n for (const action of actions) {\n const btn = doc.createElement('button');\n btn.type = 'button';\n btn.className = `minisiwyg-btn minisiwyg-btn-${action}`;\n const label = ACTION_LABELS[action] ?? action;\n btn.setAttribute('aria-label', label);\n btn.setAttribute('aria-pressed', 'false');\n btn.textContent = label;\n\n // Only first button is in tab order; rest use arrow keys\n btn.tabIndex = buttons.length === 0 ? 0 : -1;\n\n btn.addEventListener('click', () => onButtonClick(action));\n\n container.appendChild(btn);\n buttons.push(btn);\n }\n\n // Caller is responsible for placing toolbar.element in the DOM\n\n function onButtonClick(action: string): void {\n try {\n if (action === 'link') {\n const url = window.prompt('Enter URL')?.trim();\n if (!url) return;\n if (!isProtocolAllowed(url, DEFAULT_POLICY.protocols)) return;\n editor.exec('link', url);\n } else {\n editor.exec(action);\n }\n } catch {\n // Unknown or invalid commands \u2014 don't crash the toolbar\n }\n updateActiveStates();\n }\n\n function updateActiveStates(): void {\n for (let i = 0; i < buttons.length; i++) {\n const action = actions[i];\n try {\n const active = editor.queryState(action);\n buttons[i].setAttribute('aria-pressed', String(active));\n buttons[i].classList.toggle('minisiwyg-btn-active', active);\n } catch {\n // queryState may throw for unknown commands; ignore\n }\n }\n }\n\n // Keyboard navigation within toolbar\n function onKeydown(e: KeyboardEvent): void {\n const target = e.target as HTMLElement;\n const idx = buttons.indexOf(target as HTMLButtonElement);\n if (idx === -1) return;\n\n let next = -1;\n if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n e.preventDefault();\n next = (idx + 1) % buttons.length;\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n e.preventDefault();\n next = (idx - 1 + buttons.length) % buttons.length;\n } else if (e.key === 'Home') {\n e.preventDefault();\n next = 0;\n } else if (e.key === 'End') {\n e.preventDefault();\n next = buttons.length - 1;\n }\n\n if (next >= 0) {\n buttons[idx].tabIndex = -1;\n buttons[next].tabIndex = 0;\n buttons[next].focus();\n }\n }\n\n container.addEventListener('keydown', onKeydown);\n\n // Track selection changes to update active states (debounced to one per frame)\n let rafId = 0;\n function onSelectionChange(): void {\n cancelAnimationFrame(rafId);\n rafId = requestAnimationFrame(updateActiveStates);\n }\n\n doc.addEventListener('selectionchange', onSelectionChange);\n\n // Return the container element for the caller to place in the DOM\n const toolbar: Toolbar = {\n element: container,\n destroy(): void {\n cancelAnimationFrame(rafId);\n container.removeEventListener('keydown', onKeydown);\n doc.removeEventListener('selectionchange', onSelectionChange);\n // Remove buttons\n for (const btn of buttons) {\n btn.remove();\n }\n // Remove container if we created it (not user-provided)\n if (!options?.element) {\n container.remove();\n }\n buttons.length = 0;\n },\n };\n\n return toolbar;\n}\n", "/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["toolbar_exports", "__export", "createToolbar", "__toCommonJS", "DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "ACTION_LABELS", "DEFAULT_ACTIONS", "createToolbar", "editor", "options", "actions", "doc", "container", "buttons", "action", "btn", "label", "onButtonClick", "url", "isProtocolAllowed", "DEFAULT_POLICY", "updateActiveStates", "i", "active", "onKeydown", "e", "target", "idx", "next", "rafId", "onSelectionChange"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Editor, ToolbarOptions, Toolbar } from './types';
|
|
2
|
+
export type { ToolbarOptions, Toolbar } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Create a toolbar that drives an Editor instance.
|
|
5
|
+
*
|
|
6
|
+
* Renders a `<div role="toolbar">` with buttons for each action.
|
|
7
|
+
* Supports ARIA roles, keyboard navigation (arrow keys between
|
|
8
|
+
* buttons, Tab exits), and active-state tracking via selectionchange.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createToolbar(editor: Editor, options?: ToolbarOptions): Toolbar;
|
package/dist/toolbar.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var y=new Set(["javascript","data"]);function L(a){let o=a.trim();o=o.replace(/&#x([0-9a-f]+);?/gi,(s,r)=>String.fromCharCode(parseInt(r,16))),o=o.replace(/&#(\d+);?/g,(s,r)=>String.fromCharCode(parseInt(r,10)));try{o=decodeURIComponent(o)}catch{}o=o.replace(/[\s\x00-\x1f\u00A0\u1680\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,"");let l=o.match(/^([a-z][a-z0-9+\-.]*)\s*:/i);return l?l[1].toLowerCase():null}function b(a,o){let l=L(a);return l===null?!0:y.has(l)?!1:o.includes(l)}var d={tags:{p:[],br:[],strong:[],em:[],a:["href","title","target"],h1:[],h2:[],h3:[],ul:[],ol:[],li:[],blockquote:[],pre:[],code:[]},strip:!0,maxDepth:10,maxLength:1e5,protocols:["https","http","mailto"]};Object.freeze(d);Object.freeze(d.protocols);for(let a of Object.values(d.tags))Object.freeze(a);Object.freeze(d.tags);var g=d;var v={bold:"Bold",italic:"Italic",heading:"Heading",blockquote:"Blockquote",unorderedList:"Bulleted list",orderedList:"Numbered list",link:"Link",unlink:"Remove link",codeBlock:"Code block"},A=["bold","italic","heading","unorderedList","orderedList","link","codeBlock"];function O(a,o){let l=o?.actions??A,s=document,r=o?.element??s.createElement("div");r.setAttribute("role","toolbar"),r.setAttribute("aria-label","Text formatting"),r.classList.add("minisiwyg-toolbar");let n=[];for(let t of l){let e=s.createElement("button");e.type="button",e.className=`minisiwyg-btn minisiwyg-btn-${t}`;let i=v[t]??t;e.setAttribute("aria-label",i),e.setAttribute("aria-pressed","false"),e.textContent=i,e.tabIndex=n.length===0?0:-1,e.addEventListener("click",()=>h(t)),r.appendChild(e),n.push(e)}function h(t){try{if(t==="link"){let e=window.prompt("Enter URL")?.trim();if(!e||!b(e,g.protocols))return;a.exec("link",e)}else a.exec(t)}catch{}f()}function f(){for(let t=0;t<n.length;t++){let e=l[t];try{let i=a.queryState(e);n[t].setAttribute("aria-pressed",String(i)),n[t].classList.toggle("minisiwyg-btn-active",i)}catch{}}}function m(t){let e=t.target,i=n.indexOf(e);if(i===-1)return;let c=-1;t.key==="ArrowRight"||t.key==="ArrowDown"?(t.preventDefault(),c=(i+1)%n.length):t.key==="ArrowLeft"||t.key==="ArrowUp"?(t.preventDefault(),c=(i-1+n.length)%n.length):t.key==="Home"?(t.preventDefault(),c=0):t.key==="End"&&(t.preventDefault(),c=n.length-1),c>=0&&(n[i].tabIndex=-1,n[c].tabIndex=0,n[c].focus())}r.addEventListener("keydown",m);let u=0;function p(){cancelAnimationFrame(u),u=requestAnimationFrame(f)}return s.addEventListener("selectionchange",p),{element:r,destroy(){cancelAnimationFrame(u),r.removeEventListener("keydown",m),s.removeEventListener("selectionchange",p);for(let t of n)t.remove();o?.element||r.remove(),n.length=0}}}export{O as createToolbar};
|
|
2
|
+
//# sourceMappingURL=toolbar.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/shared.ts", "../src/defaults.ts", "../src/toolbar.ts"],
|
|
4
|
+
"sourcesContent": ["/** Tag normalization map: browser-variant tags \u2192 semantic equivalents. */\nexport const TAG_NORMALIZE: Record<string, string> = {\n b: 'strong',\n i: 'em',\n};\n\n/** Attributes that contain URLs and need protocol validation. */\nexport const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction']);\n\n/** Protocols that are always denied regardless of policy. */\nexport const DENIED_PROTOCOLS = new Set(['javascript', 'data']);\n\n/**\n * Parse a URL-like string and extract the protocol.\n * Returns the lowercase protocol name (without colon), or null if none found.\n */\nexport function extractProtocol(value: string): string | null {\n let decoded = value.trim();\n decoded = decoded.replace(/&#x([0-9a-f]+);?/gi, (_, hex) =>\n String.fromCharCode(parseInt(hex, 16)),\n );\n decoded = decoded.replace(/&#(\\d+);?/g, (_, dec) =>\n String.fromCharCode(parseInt(dec, 10)),\n );\n try {\n decoded = decodeURIComponent(decoded);\n } catch {\n // keep entity-decoded result\n }\n decoded = decoded.replace(/[\\s\\x00-\\x1f\\u00A0\\u1680\\u2000-\\u200B\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]+/g, '');\n const match = decoded.match(/^([a-z][a-z0-9+\\-.]*)\\s*:/i);\n return match ? match[1].toLowerCase() : null;\n}\n\n/**\n * Check if a URL value is allowed by the given protocol list.\n * javascript: and data: are always denied.\n */\nexport function isProtocolAllowed(value: string, allowedProtocols: string[]): boolean {\n const protocol = extractProtocol(value);\n if (protocol === null) return true;\n if (DENIED_PROTOCOLS.has(protocol)) return false;\n return allowedProtocols.includes(protocol);\n}\n", "import type { SanitizePolicy } from './types';\n\nconst policy: SanitizePolicy = {\n tags: {\n p: [],\n br: [],\n strong: [],\n em: [],\n a: ['href', 'title', 'target'],\n h1: [],\n h2: [],\n h3: [],\n ul: [],\n ol: [],\n li: [],\n blockquote: [],\n pre: [],\n code: [],\n },\n strip: true,\n maxDepth: 10,\n maxLength: 100_000,\n protocols: ['https', 'http', 'mailto'],\n};\n\n// Deep freeze to prevent mutation of security-critical defaults\nObject.freeze(policy);\nObject.freeze(policy.protocols);\nfor (const attrs of Object.values(policy.tags)) Object.freeze(attrs);\nObject.freeze(policy.tags);\n\nexport const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;\n", "import type { Editor, ToolbarOptions, Toolbar } from './types';\nimport { isProtocolAllowed } from './shared';\nimport { DEFAULT_POLICY } from './defaults';\n\nexport type { ToolbarOptions, Toolbar } from './types';\n\nconst ACTION_LABELS: Record<string, string> = {\n bold: 'Bold',\n italic: 'Italic',\n heading: 'Heading',\n blockquote: 'Blockquote',\n unorderedList: 'Bulleted list',\n orderedList: 'Numbered list',\n link: 'Link',\n unlink: 'Remove link',\n codeBlock: 'Code block',\n};\n\nconst DEFAULT_ACTIONS = [\n 'bold',\n 'italic',\n 'heading',\n 'unorderedList',\n 'orderedList',\n 'link',\n 'codeBlock',\n];\n\n/**\n * Create a toolbar that drives an Editor instance.\n *\n * Renders a `<div role=\"toolbar\">` with buttons for each action.\n * Supports ARIA roles, keyboard navigation (arrow keys between\n * buttons, Tab exits), and active-state tracking via selectionchange.\n */\nexport function createToolbar(\n editor: Editor,\n options?: ToolbarOptions,\n): Toolbar {\n const actions = options?.actions ?? DEFAULT_ACTIONS;\n const doc = document;\n\n // Container\n const container = options?.element ?? doc.createElement('div');\n container.setAttribute('role', 'toolbar');\n container.setAttribute('aria-label', 'Text formatting');\n container.classList.add('minisiwyg-toolbar');\n\n const buttons: HTMLButtonElement[] = [];\n\n for (const action of actions) {\n const btn = doc.createElement('button');\n btn.type = 'button';\n btn.className = `minisiwyg-btn minisiwyg-btn-${action}`;\n const label = ACTION_LABELS[action] ?? action;\n btn.setAttribute('aria-label', label);\n btn.setAttribute('aria-pressed', 'false');\n btn.textContent = label;\n\n // Only first button is in tab order; rest use arrow keys\n btn.tabIndex = buttons.length === 0 ? 0 : -1;\n\n btn.addEventListener('click', () => onButtonClick(action));\n\n container.appendChild(btn);\n buttons.push(btn);\n }\n\n // Caller is responsible for placing toolbar.element in the DOM\n\n function onButtonClick(action: string): void {\n try {\n if (action === 'link') {\n const url = window.prompt('Enter URL')?.trim();\n if (!url) return;\n if (!isProtocolAllowed(url, DEFAULT_POLICY.protocols)) return;\n editor.exec('link', url);\n } else {\n editor.exec(action);\n }\n } catch {\n // Unknown or invalid commands \u2014 don't crash the toolbar\n }\n updateActiveStates();\n }\n\n function updateActiveStates(): void {\n for (let i = 0; i < buttons.length; i++) {\n const action = actions[i];\n try {\n const active = editor.queryState(action);\n buttons[i].setAttribute('aria-pressed', String(active));\n buttons[i].classList.toggle('minisiwyg-btn-active', active);\n } catch {\n // queryState may throw for unknown commands; ignore\n }\n }\n }\n\n // Keyboard navigation within toolbar\n function onKeydown(e: KeyboardEvent): void {\n const target = e.target as HTMLElement;\n const idx = buttons.indexOf(target as HTMLButtonElement);\n if (idx === -1) return;\n\n let next = -1;\n if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n e.preventDefault();\n next = (idx + 1) % buttons.length;\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n e.preventDefault();\n next = (idx - 1 + buttons.length) % buttons.length;\n } else if (e.key === 'Home') {\n e.preventDefault();\n next = 0;\n } else if (e.key === 'End') {\n e.preventDefault();\n next = buttons.length - 1;\n }\n\n if (next >= 0) {\n buttons[idx].tabIndex = -1;\n buttons[next].tabIndex = 0;\n buttons[next].focus();\n }\n }\n\n container.addEventListener('keydown', onKeydown);\n\n // Track selection changes to update active states (debounced to one per frame)\n let rafId = 0;\n function onSelectionChange(): void {\n cancelAnimationFrame(rafId);\n rafId = requestAnimationFrame(updateActiveStates);\n }\n\n doc.addEventListener('selectionchange', onSelectionChange);\n\n // Return the container element for the caller to place in the DOM\n const toolbar: Toolbar = {\n element: container,\n destroy(): void {\n cancelAnimationFrame(rafId);\n container.removeEventListener('keydown', onKeydown);\n doc.removeEventListener('selectionchange', onSelectionChange);\n // Remove buttons\n for (const btn of buttons) {\n btn.remove();\n }\n // Remove container if we created it (not user-provided)\n if (!options?.element) {\n container.remove();\n }\n buttons.length = 0;\n },\n };\n\n return toolbar;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["DENIED_PROTOCOLS", "extractProtocol", "value", "decoded", "_", "hex", "dec", "match", "isProtocolAllowed", "allowedProtocols", "protocol", "policy", "attrs", "DEFAULT_POLICY", "ACTION_LABELS", "DEFAULT_ACTIONS", "createToolbar", "editor", "options", "actions", "doc", "container", "buttons", "action", "btn", "label", "onButtonClick", "url", "isProtocolAllowed", "DEFAULT_POLICY", "updateActiveStates", "i", "active", "onKeydown", "e", "target", "idx", "next", "rafId", "onSelectionChange"]
|
|
7
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative sanitization policy.
|
|
3
|
+
* JSON-serializable so policies can be stored, transmitted, and validated.
|
|
4
|
+
*/
|
|
5
|
+
export interface SanitizePolicy {
|
|
6
|
+
/** Allowed tags mapped to their allowed attributes. */
|
|
7
|
+
tags: Record<string, string[]>;
|
|
8
|
+
/** When true, disallowed nodes are removed entirely. When false, they are unwrapped (text kept). */
|
|
9
|
+
strip: boolean;
|
|
10
|
+
/** Maximum nesting depth. Nodes deeper than this are removed. */
|
|
11
|
+
maxDepth: number;
|
|
12
|
+
/** Maximum textContent length. Content beyond this is truncated. */
|
|
13
|
+
maxLength: number;
|
|
14
|
+
/** Allowed URL protocols for href, src, action attributes. */
|
|
15
|
+
protocols: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface EditorOptions {
|
|
18
|
+
policy?: SanitizePolicy;
|
|
19
|
+
onChange?: (html: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface Editor {
|
|
22
|
+
exec(command: string, value?: string): void;
|
|
23
|
+
queryState(command: string): boolean;
|
|
24
|
+
getHTML(): string;
|
|
25
|
+
getText(): string;
|
|
26
|
+
destroy(): void;
|
|
27
|
+
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
28
|
+
}
|
|
29
|
+
export interface ToolbarOptions {
|
|
30
|
+
actions?: string[];
|
|
31
|
+
element?: HTMLElement;
|
|
32
|
+
}
|
|
33
|
+
export interface Toolbar {
|
|
34
|
+
/** The toolbar container element. Place this in the DOM. */
|
|
35
|
+
element: HTMLElement;
|
|
36
|
+
destroy(): void;
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "minisiwyg-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A sub-5kb, zero-dependency WYSIWYG editor with built-in XSS protection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./sanitize": {
|
|
16
|
+
"types": "./dist/sanitize.d.ts",
|
|
17
|
+
"import": "./dist/sanitize.js",
|
|
18
|
+
"require": "./dist/sanitize.cjs"
|
|
19
|
+
},
|
|
20
|
+
"./policy": {
|
|
21
|
+
"types": "./dist/policy.d.ts",
|
|
22
|
+
"import": "./dist/policy.js",
|
|
23
|
+
"require": "./dist/policy.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./toolbar": {
|
|
26
|
+
"types": "./dist/toolbar.d.ts",
|
|
27
|
+
"import": "./dist/toolbar.js",
|
|
28
|
+
"require": "./dist/toolbar.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && node esbuild.config.js && tsc --emitDeclarationOnly",
|
|
37
|
+
"build:demo": "npm run build && node scripts/build-demo.js",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:browser": "npx playwright test",
|
|
41
|
+
"size-check": "node scripts/size-check.js",
|
|
42
|
+
"typecheck": "tsc --noEmit"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"wysiwyg",
|
|
46
|
+
"editor",
|
|
47
|
+
"rich-text",
|
|
48
|
+
"contenteditable",
|
|
49
|
+
"xss",
|
|
50
|
+
"sanitizer",
|
|
51
|
+
"security",
|
|
52
|
+
"lightweight",
|
|
53
|
+
"zero-dependency"
|
|
54
|
+
],
|
|
55
|
+
"author": "",
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "https://github.com/erikleon/minisiwyg-editor"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://erikleon.github.io/minisiwyg-editor/",
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/erikleon/minisiwyg-editor/issues"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=20"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"esbuild": "^0.25.0",
|
|
70
|
+
"typescript": "^5.7.0",
|
|
71
|
+
"vitest": "^3.0.0",
|
|
72
|
+
"happy-dom": "^17.0.0",
|
|
73
|
+
"@playwright/test": "^1.50.0"
|
|
74
|
+
}
|
|
75
|
+
}
|