diff --git a/src/plugin/__tests__/obsidian-stubs-html.test.ts b/src/plugin/__tests__/obsidian-stubs-html.test.ts index 82e30645..d62ab065 100644 --- a/src/plugin/__tests__/obsidian-stubs-html.test.ts +++ b/src/plugin/__tests__/obsidian-stubs-html.test.ts @@ -1,6 +1,6 @@ // src/plugin/__tests__/obsidian-stubs-html.test.ts import { describe, it, expect } from 'vitest' -import { htmlToMarkdown, MarkdownRenderer } from '../obsidian-stubs' +import { htmlToMarkdown, sanitizeHTMLToDom, MarkdownRenderer } from '../obsidian-stubs' // Realm-identity: htmlToMarkdown must be the same function reference whether // imported via the ESM `obsidian-compat` virtual module path or the CJS // `loader/plugin-api-global` shim. Drift here means plugins loaded by @@ -386,6 +386,415 @@ describe('htmlToMarkdown', () => { }) }) +describe('sanitizeHTMLToDom', () => { + describe('basic functionality', () => { + it('returns a DocumentFragment', () => { + const result = sanitizeHTMLToDom('

hello

') + expect(result).toBeInstanceOf(DocumentFragment) + }) + + it('preserves safe HTML content', () => { + const result = sanitizeHTMLToDom('

Hello world

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.innerHTML).toBe('

Hello world

') + }) + + it('handles empty string input', () => { + const result = sanitizeHTMLToDom('') + expect(result).toBeInstanceOf(DocumentFragment) + expect(result.childNodes.length).toBe(0) + }) + + it('handles null input defensively', () => { + // @ts-expect-error testing runtime guard for null + const result = sanitizeHTMLToDom(null) + expect(result).toBeInstanceOf(DocumentFragment) + expect(result.childNodes.length).toBe(0) + }) + + it('handles undefined input defensively', () => { + // @ts-expect-error testing runtime guard for undefined + const result = sanitizeHTMLToDom(undefined) + expect(result).toBeInstanceOf(DocumentFragment) + expect(result.childNodes.length).toBe(0) + }) + }) + + describe('dangerous element removal', () => { + it('removes script elements', () => { + const result = sanitizeHTMLToDom('

before

after

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.innerHTML).toBe('

before

after

') + expect(div.querySelector('script')).toBeNull() + }) + + it('removes style elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.innerHTML).toBe('

text

') + expect(div.querySelector('style')).toBeNull() + }) + + it('removes iframe elements', () => { + const result = sanitizeHTMLToDom('
safe
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('iframe')).toBeNull() + }) + + it('removes object elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('object')).toBeNull() + }) + + it('removes embed elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('embed')).toBeNull() + }) + + it('removes base elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('base')).toBeNull() + }) + + it('removes meta elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('meta')).toBeNull() + }) + + it('removes template elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('template')).toBeNull() + }) + + it('removes math elements', () => { + const result = sanitizeHTMLToDom('x

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('math')).toBeNull() + }) + + it('removes frame elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('frame')).toBeNull() + }) + + it('removes frameset elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('frameset')).toBeNull() + }) + + it('removes noscript elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('noscript')).toBeNull() + }) + + it('removes link elements', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('link')).toBeNull() + }) + + it('handles deeply nested dangerous content', () => { + const result = sanitizeHTMLToDom('

safe

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('script')).toBeNull() + expect(div.innerHTML).toContain('safe') + }) + }) + + describe('dangerous attribute removal', () => { + it('removes onclick handlers', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('button')?.hasAttribute('onclick')).toBe(false) + }) + + it('removes onerror handlers', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('img')?.hasAttribute('onerror')).toBe(false) + }) + + it('removes onload handlers', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('img')?.hasAttribute('onload')).toBe(false) + }) + + it('removes onmouseover handlers', () => { + const result = sanitizeHTMLToDom('
Hover me
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('div')?.hasAttribute('onmouseover')).toBe(false) + }) + + it('removes onpointerdown handlers (touch/pointer events)', () => { + const result = sanitizeHTMLToDom('
Touch me
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('div')?.hasAttribute('onpointerdown')).toBe(false) + }) + + it('removes ontouchstart handlers', () => { + const result = sanitizeHTMLToDom('
Touch me
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('div')?.hasAttribute('ontouchstart')).toBe(false) + }) + + it('removes onanimationstart handlers', () => { + const result = sanitizeHTMLToDom('
Animate
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('div')?.hasAttribute('onanimationstart')).toBe(false) + }) + + it('removes formaction attribute', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('button')?.hasAttribute('formaction')).toBe(false) + }) + + it('removes srcdoc attribute', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + // iframe is removed entirely, but let's also test srcdoc on an element that stays + const result2 = sanitizeHTMLToDom('
') + const div2 = document.createElement('div') + div2.appendChild(result2) + expect(div2.querySelector('div')?.hasAttribute('srcdoc')).toBe(false) + }) + + it('preserves safe attributes', () => { + const result = sanitizeHTMLToDom('Link') + const div = document.createElement('div') + div.appendChild(result) + const anchor = div.querySelector('a') + expect(anchor?.getAttribute('href')).toBe('https://example.com') + expect(anchor?.getAttribute('class')).toBe('link') + expect(anchor?.getAttribute('id')).toBe('mylink') + }) + }) + + describe('javascript: URL sanitization', () => { + it('removes javascript: href', () => { + const result = sanitizeHTMLToDom('Click') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('a')?.hasAttribute('href')).toBe(false) + }) + + it('removes javascript: href with whitespace', () => { + const result = sanitizeHTMLToDom('Click') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('a')?.hasAttribute('href')).toBe(false) + }) + + it('removes data:text/html src', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('img')?.hasAttribute('src')).toBe(false) + }) + + it('preserves safe data: URLs', () => { + // data:image/png is safe and commonly used + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,ABC123') + }) + + it('removes vbscript: URLs', () => { + const result = sanitizeHTMLToDom('Click') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('a')?.hasAttribute('href')).toBe(false) + }) + + it('removes data:image/svg+xml (can contain scripts)', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('img')?.hasAttribute('src')).toBe(false) + }) + + it('removes data:application/xhtml+xml', () => { + const result = sanitizeHTMLToDom('Click') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('a')?.hasAttribute('href')).toBe(false) + }) + + it('removes javascript: from poster attribute', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('video')?.hasAttribute('poster')).toBe(false) + }) + + it('removes javascript: from cite attribute', () => { + const result = sanitizeHTMLToDom('
quote
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('blockquote')?.hasAttribute('cite')).toBe(false) + }) + }) + + describe('SVG sanitization', () => { + it('removes script elements inside SVG', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('script')).toBeNull() + // SVG itself should remain + expect(div.querySelector('svg')).not.toBeNull() + }) + + it('removes onload handlers on SVG elements', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('svg')?.hasAttribute('onload')).toBe(false) + }) + + it('removes foreignObject inside SVG', () => { + const result = sanitizeHTMLToDom('
content
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('foreignObject')).toBeNull() + }) + + it('removes href from SVG use elements', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + const useEl = div.querySelector('use') + expect(useEl).not.toBeNull() + expect(useEl?.hasAttribute('href')).toBe(false) + }) + + it('removes xlink:href from SVG use elements', () => { + const result = sanitizeHTMLToDom('') + const div = document.createElement('div') + div.appendChild(result) + const useEl = div.querySelector('use') + expect(useEl).not.toBeNull() + expect(useEl?.hasAttribute('xlink:href')).toBe(false) + }) + + it('removes javascript: href from SVG a elements', () => { + const result = sanitizeHTMLToDom('click') + const div = document.createElement('div') + div.appendChild(result) + const anchor = div.querySelector('a') + expect(anchor).not.toBeNull() + expect(anchor?.hasAttribute('href')).toBe(false) + }) + + it('removes javascript: xlink:href from SVG a elements', () => { + const result = sanitizeHTMLToDom('click') + const div = document.createElement('div') + div.appendChild(result) + const anchor = div.querySelector('a') + expect(anchor).not.toBeNull() + expect(anchor?.hasAttribute('xlink:href')).toBe(false) + }) + }) + + describe('case sensitivity', () => { + it('handles uppercase SCRIPT tags', () => { + const result = sanitizeHTMLToDom('

text

') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('script')).toBeNull() + expect(div.innerHTML).toBe('

text

') + }) + + it('handles uppercase event handlers', () => { + const result = sanitizeHTMLToDom('
Click
') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('div')?.hasAttribute('onclick')).toBe(false) + expect(div.querySelector('div')?.hasAttribute('ONCLICK')).toBe(false) + }) + + it('handles uppercase JAVASCRIPT: protocol', () => { + const result = sanitizeHTMLToDom('Click') + const div = document.createElement('div') + div.appendChild(result) + expect(div.querySelector('a')?.hasAttribute('href')).toBe(false) + }) + }) + + describe('complex HTML structures', () => { + it('handles nested elements with mixed dangerous content', () => { + const html = ` +
+

Safe text

+ + More safe text +
+ ` + const result = sanitizeHTMLToDom(html) + const div = document.createElement('div') + div.appendChild(result) + + expect(div.querySelector('script')).toBeNull() + expect(div.querySelector('p')?.hasAttribute('onclick')).toBe(false) + expect(div.textContent).toContain('Safe text') + expect(div.textContent).toContain('More safe text') + }) + + it('handles multiple dangerous elements', () => { + const html = '

safe

' + const result = sanitizeHTMLToDom(html) + const div = document.createElement('div') + div.appendChild(result) + + expect(div.querySelector('script')).toBeNull() + expect(div.querySelector('style')).toBeNull() + expect(div.querySelector('iframe')).toBeNull() + expect(div.innerHTML).toBe('

safe

') + }) + }) + + describe('realm identity', () => { + it('obsidian-compat re-exports the same sanitizeHTMLToDom reference', () => { + // Import from obsidian-compat to verify the export works correctly + expect(obsidianCompat.sanitizeHTMLToDom).toBe(sanitizeHTMLToDom) + }) + }) +}) + describe('MarkdownRenderer', () => { it('renderMarkdown renders markdown to HTML', async () => { const el = document.createElement('div') diff --git a/src/plugin/loader/plugin-api-global.ts b/src/plugin/loader/plugin-api-global.ts index 3c2cb68a..2c5aad00 100644 --- a/src/plugin/loader/plugin-api-global.ts +++ b/src/plugin/loader/plugin-api-global.ts @@ -231,6 +231,7 @@ import { // import from 'reams' instead. Platform, htmlToMarkdown, + sanitizeHTMLToDom, MarkdownRenderer, // Bases utility functions and value types (Obsidian 1.10.0+) BasesEntry, @@ -584,6 +585,7 @@ const pluginApi: Record = { Platform, moment: momentLib, htmlToMarkdown, + sanitizeHTMLToDom, MarkdownRenderer, // Bases utility functions and value types (Obsidian 1.10.0+) BasesEntry, diff --git a/src/plugin/obsidian-compat/index.ts b/src/plugin/obsidian-compat/index.ts index 0c130188..ca76393c 100644 --- a/src/plugin/obsidian-compat/index.ts +++ b/src/plugin/obsidian-compat/index.ts @@ -268,6 +268,7 @@ export { Scope, Keymap, htmlToMarkdown, + sanitizeHTMLToDom, MarkdownRenderer, // Search helpers (Obsidian-compatible) prepareFuzzySearch, diff --git a/src/plugin/obsidian-stubs.ts b/src/plugin/obsidian-stubs.ts index 01f3c68e..8c711385 100644 --- a/src/plugin/obsidian-stubs.ts +++ b/src/plugin/obsidian-stubs.ts @@ -5605,6 +5605,165 @@ export function htmlToMarkdown(html: string | HTMLElement | DocumentFragment | D return turndownService.turndown(html) } +/** + * Dangerous HTML elements to remove during sanitization. + * Includes both HTML and SVG dangerous elements. + * @internal + */ +const SANITIZE_REMOVE_ELEMENTS = new Set([ + 'script', + 'style', + 'iframe', + 'frame', + 'frameset', + 'object', + 'embed', + 'applet', + 'noscript', + 'base', + 'meta', + 'link', + 'template', // Template content becomes active when cloned + 'math', // MathML can contain scripts +]) + +/** + * Dangerous URL protocols to remove from href/src/action/xlink:href attributes. + * @internal + */ +const DANGEROUS_URL_PROTOCOLS = [ + 'javascript:', + 'vbscript:', + 'data:text/html', + 'data:text/javascript', + 'data:application/javascript', + 'data:application/xhtml+xml', + 'data:image/svg+xml', // SVG can contain scripts +] + +/** + * Check if an attribute name is an event handler. + * Event handlers follow the pattern "on*" (case-insensitive). + * @internal + */ +function isEventHandlerAttribute(attrName: string): boolean { + return attrName.startsWith('on') +} + +/** + * Other dangerous attributes that should be removed. + * @internal + */ +const OTHER_DANGEROUS_ATTRIBUTES = new Set([ + 'formaction', // Can redirect form submissions + 'srcdoc', // Can contain arbitrary HTML in iframes (though iframes are removed) +]) + +/** + * Parse and sanitize an HTML string, returning a safe DocumentFragment. + * + * Mirrors Obsidian's `sanitizeHTMLToDom(html: string): DocumentFragment` signature. + * + * This function: + * - Parses the HTML string using DOMParser + * - Removes dangerous elements (script, style, iframe, template, math, etc.) + * - Removes ALL event handler attributes (any attribute starting with "on") + * - Removes dangerous protocol URLs (javascript:, vbscript:, data:text/html, etc.) + * - Handles both HTML and SVG elements + * - Returns a DocumentFragment containing the sanitized nodes + * + * @param html - The HTML string to sanitize. Null/undefined returns empty fragment. + * @returns A DocumentFragment containing the sanitized HTML + * @public + */ +export function sanitizeHTMLToDom(html: string): DocumentFragment { + // Defensive guard for null/undefined - plugins are sloppy + if (html == null) { + return document.createDocumentFragment() + } + + // Parse HTML using DOMParser for safe parsing + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + + // Remove dangerous elements - use querySelectorAll to handle namespaces + // getElementsByTagName is case-insensitive in HTML and handles SVG elements + for (const tagName of SANITIZE_REMOVE_ELEMENTS) { + // Use querySelectorAll for namespace-aware selection + const elements = doc.body.querySelectorAll(tagName) + elements.forEach(el => el.remove()) + } + + // Handle SVG-specific dangerous elements + // foreignObject can contain arbitrary HTML with event handlers + // can reference external SVG files that may contain scripts + // Note: script elements inside SVG are already handled by SANITIZE_REMOVE_ELEMENTS + const foreignObjects = doc.body.querySelectorAll('foreignObject') + foreignObjects.forEach(el => el.remove()) + + // Remove href/xlink:href from SVG elements (can load external scripts) + const useElements = doc.body.querySelectorAll('use') + useElements.forEach(el => { + el.removeAttribute('href') + el.removeAttribute('xlink:href') + }) + + // Remove dangerous attributes and sanitize URLs + // Use querySelectorAll('*') to get all elements including SVG children + const allElements = doc.body.querySelectorAll('*') + for (const el of allElements) { + const attrs = Array.from(el.attributes) + + for (const attr of attrs) { + const attrName = attr.name.toLowerCase() + // Strip namespace prefixes for checking (e.g., xlink:href -> href) + const localName = attrName.includes(':') ? attrName.split(':')[1] : attrName + + // Remove ALL event handlers (any on* attribute) + if (isEventHandlerAttribute(localName)) { + el.removeAttribute(attr.name) + continue + } + + // Remove other dangerous attributes + if (OTHER_DANGEROUS_ATTRIBUTES.has(localName)) { + el.removeAttribute(attr.name) + continue + } + + // Sanitize URL attributes for dangerous protocols + // Check both regular and xlink: variants + // Include all attributes that can contain URLs + if ( + localName === 'href' || + localName === 'src' || + localName === 'action' || + localName === 'poster' || //