diff --git a/packages/quill/package.json b/packages/quill/package.json index 6f2598e512..6014a9af9f 100644 --- a/packages/quill/package.json +++ b/packages/quill/package.json @@ -1,6 +1,6 @@ { "name": "quill", - "version": "2.0.3", + "version": "1.3.7-snyk-fix.1", "description": "Your powerful, rich text editor", "author": "Jason Chen ", "homepage": "https://quilljs.com", diff --git a/packages/quill/src/blots/break.ts b/packages/quill/src/blots/break.ts index db4c2a7faf..9b196aa098 100644 --- a/packages/quill/src/blots/break.ts +++ b/packages/quill/src/blots/break.ts @@ -18,6 +18,10 @@ class Break extends EmbedBlot { value() { return ''; } + + html() { + return '
'; + } } Break.blotName = 'break'; Break.tagName = 'BR'; diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts index b6391a7cd4..bbbfc8c665 100644 --- a/packages/quill/src/core/editor.ts +++ b/packages/quill/src/core/editor.ts @@ -7,6 +7,10 @@ import Break from '../blots/break.js'; import CursorBlot from '../blots/cursor.js'; import type Scroll from '../blots/scroll.js'; import TextBlot, { escapeText } from '../blots/text.js'; +import { + cloneWithSafeAttributes, + getSafeWrapperMarkup, +} from './html_escape.js'; import { Range } from './selection.js'; const ASCII = /^[ -~]*$/; @@ -399,15 +403,20 @@ function convertHTML( if (isRoot || blot.statics.blotName === 'list') { return parts.join(''); } - const { outerHTML, innerHTML } = blot.domNode as Element; - const [start, end] = outerHTML.split(`>${innerHTML}<`); - // TODO cleanup - if (start === '${parts.join('')}<${end}`; + const domNode = blot.domNode as Element; + const [start, end] = getSafeWrapperMarkup(domNode); + if (domNode.tagName === 'TABLE') { + return `${parts.join('')}${end}`; } - return `${start}>${parts.join('')}<${end}`; + if (end === '') { + return `${start}${parts.join('')}`; + } + return `${start}${parts.join('')}${end}`; + } + if (blot.domNode instanceof Element) { + return cloneWithSafeAttributes(blot.domNode).outerHTML; } - return blot.domNode instanceof Element ? blot.domNode.outerHTML : ''; + return ''; } function combineFormats( diff --git a/packages/quill/src/core/html_escape.ts b/packages/quill/src/core/html_escape.ts new file mode 100644 index 0000000000..b20f2a1987 --- /dev/null +++ b/packages/quill/src/core/html_escape.ts @@ -0,0 +1,76 @@ +import { escapeText } from '../blots/text.js'; + +/** HTML body / text node context (same as TextBlot escape). */ +export const escapeHtml = escapeText; + +/** Double-quoted attribute context. */ +export function escapeAttribute(value: string): string { + return value.replace(/[&<>"'\n\r\t]/g, (ch) => { + switch (ch) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + case '\n': + return ' '; + case '\r': + return ' '; + case '\t': + return ' '; + default: + return ch; + } + }); +} + +export function stripDangerousAttributes(element: Element): void { + for (const attr of Array.from(element.attributes)) { + const name = attr.name.toLowerCase(); + if (name.startsWith('on')) { + element.removeAttribute(attr.name); + continue; + } + const val = attr.value; + if ( + (name === 'href' || name === 'src' || name === 'xlink:href') && + /^\s*javascript:/i.test(val) + ) { + element.removeAttribute(attr.name); + } + } +} + +const EXPORT_MARKER = '\uE000QUILL_HTML_EXPORT\uE000'; + +/** + * Safe open/close fragments for a non-void element by cloning the shell, + * stripping dangerous attributes, and replacing inner HTML with a marker. + */ +export function getSafeWrapperMarkup(domNode: Element): [string, string] { + const shell = domNode.cloneNode(false) as Element; + stripDangerousAttributes(shell); + shell.innerHTML = EXPORT_MARKER; + const outer = shell.outerHTML; + const idx = outer.indexOf(EXPORT_MARKER); + if (idx === -1) { + return [shell.outerHTML, '']; + } + return [outer.slice(0, idx), outer.slice(idx + EXPORT_MARKER.length)]; +} + +/** Clone subtree and strip event handlers / javascript: URLs from every element. */ +export function cloneWithSafeAttributes(root: Element): Element { + const clone = root.cloneNode(true) as Element; + const walk = (el: Element) => { + stripDangerousAttributes(el); + Array.from(el.children).forEach((ch) => walk(ch)); + }; + walk(clone); + return clone; +} diff --git a/packages/quill/src/formats/formula.ts b/packages/quill/src/formats/formula.ts index ded4e89160..5c303aa8b9 100644 --- a/packages/quill/src/formats/formula.ts +++ b/packages/quill/src/formats/formula.ts @@ -1,4 +1,5 @@ import Embed from '../blots/embed.js'; +import { escapeHtml } from '../core/html_escape.js'; class Formula extends Embed { static blotName = 'formula'; @@ -27,8 +28,12 @@ class Formula extends Embed { } html() { - const { formula } = this.value(); - return `${formula}`; + const dom = this.domNode as Element; + const raw = + (typeof this.value() === 'string' ? this.value() : null) ?? + dom.getAttribute('data-value') ?? + ''; + return `${escapeHtml(raw)}`; } } diff --git a/packages/quill/src/formats/image.ts b/packages/quill/src/formats/image.ts index e68f56a0b3..5e311bb632 100644 --- a/packages/quill/src/formats/image.ts +++ b/packages/quill/src/formats/image.ts @@ -1,4 +1,5 @@ import { EmbedBlot } from 'parchment'; +import { escapeAttribute } from '../core/html_escape.js'; import { sanitize } from './link.js'; const ATTRIBUTES = ['alt', 'height', 'width']; @@ -52,6 +53,17 @@ class Image extends EmbedBlot { super.format(name, value); } } + + html() { + const src = Image.sanitize(this.domNode.getAttribute('src') || ''); + const formats = Image.formats(this.domNode); + const parts = [`src="${escapeAttribute(src)}"`]; + ATTRIBUTES.forEach((name) => { + const v = formats[name]; + if (v) parts.push(`${name}="${escapeAttribute(v)}"`); + }); + return ``; + } } export default Image; diff --git a/packages/quill/src/formats/video.ts b/packages/quill/src/formats/video.ts index 84d4bb15cf..f21f505320 100644 --- a/packages/quill/src/formats/video.ts +++ b/packages/quill/src/formats/video.ts @@ -1,4 +1,5 @@ import { BlockEmbed } from '../blots/block.js'; +import { escapeAttribute, escapeHtml } from '../core/html_escape.js'; import Link from './link.js'; const ATTRIBUTES = ['height', 'width']; @@ -51,8 +52,9 @@ class Video extends BlockEmbed { } html() { - const { video } = this.value(); - return `${video}`; + const raw = this.domNode.getAttribute('src') ?? ''; + const safe = Video.sanitize(raw); + return `${escapeHtml(safe)}`; } } diff --git a/packages/quill/src/modules/syntax.ts b/packages/quill/src/modules/syntax.ts index da99fdc41d..685bb5eb0c 100644 --- a/packages/quill/src/modules/syntax.ts +++ b/packages/quill/src/modules/syntax.ts @@ -3,6 +3,7 @@ import { ClassAttributor, Scope } from 'parchment'; import type { Blot, ScrollBlot } from 'parchment'; import Inline from '../blots/inline.js'; import Quill from '../core/quill.js'; +import { escapeAttribute } from '../core/html_escape.js'; import Module from '../core/module.js'; import { blockDelta } from '../blots/block.js'; import BreakBlot from '../blots/break.js'; @@ -159,7 +160,7 @@ class SyntaxCodeBlockContainer extends CodeBlockContainer { ? SyntaxCodeBlock.formats(codeBlock.domNode) : 'plain'; - return `
\n${escapeText(
+    return `
\n${escapeText(
       this.code(index, length),
     )}\n
`; } diff --git a/packages/quill/test/unit/core/editor.spec.ts b/packages/quill/test/unit/core/editor.spec.ts index 2d47a1dec9..3fcbedfd0e 100644 --- a/packages/quill/test/unit/core/editor.spec.ts +++ b/packages/quill/test/unit/core/editor.spec.ts @@ -25,6 +25,7 @@ import { SizeClass } from '../../../src/formats/size.js'; import Blockquote from '../../../src/formats/blockquote.js'; import IndentClass from '../../../src/formats/indent.js'; import { ColorClass } from '../../../src/formats/color.js'; +import Formula from '../../../src/formats/formula.js'; import Quill from '../../../src/core.js'; import { normalizeHTML } from '../__helpers__/utils.js'; @@ -1409,5 +1410,76 @@ describe('Editor', () => { expect(editor.getHTML(2, 7)).toEqual('
\n123\n\n\n4\n
'); expect(editor.getHTML(5, 7)).toEqual('
\n\n\n\n4567\n
'); }); + + test('video html export escapes href and text for XSS payloads (CVE-2025-15056)', () => { + const editor = createEditor( + new Delta().insert({ video: 'https://example.test/' }).insert('\n'), + ); + const iframe = editor.scroll.domNode.querySelector( + 'iframe.ql-video', + ) as HTMLIFrameElement; + iframe.setAttribute( + 'src', + 'https://example.test/path?x=" onmouseover="alert(1)', + ); + const html = editor.getHTML(0, editor.scroll.length() - 1); + expect(html).not.toMatch(/onmouseover/i); + expect(html).toContain('"'); + }); + + test('formula html export escapes injected markup (CVE-2025-15056)', () => { + const prevKatex = (window as unknown as { katex?: unknown }).katex; + (window as unknown as { katex: { render: () => void } }).katex = { + render: () => {}, + }; + const container = document.createElement('div'); + document.body.appendChild(container); + const quill = new Quill(container, { + registry: createRegistry([ + ListContainer, + List, + IndentClass, + Bold, + Image, + ColorClass, + Link, + FontClass, + Header, + Italic, + AlignClass, + Video, + Strike, + Underline, + CodeBlock, + CodeBlockContainer, + Blockquote, + SizeClass, + Formula, + ]), + }); + quill.setContents( + new Delta() + .insert({ formula: '' }) + .insert('\n'), + ); + const html = quill.editor.getHTML(0, quill.getLength() - 1); + expect(html).not.toMatch(/ { + const editor = createEditor( + '
Quill
', + ); + const html = editor.getHTML(0, 6); + expect(html).not.toMatch(/onmouseover/i); + expect(html).toContain('Quill'); + }); }); }); diff --git a/packages/quill/test/unit/vitest.config.ts b/packages/quill/test/unit/vitest.config.ts index b279a46078..fdc7e26e1b 100644 --- a/packages/quill/test/unit/vitest.config.ts +++ b/packages/quill/test/unit/vitest.config.ts @@ -6,10 +6,10 @@ export default defineConfig({ extensions: ['.ts', '.js'], }, test: { - include: [resolve(__dirname, '**/*.spec.ts')], + include: ['test/unit/**/*.spec.ts'], typecheck: { enabled: true, - include: [resolve(__dirname, '**/*.test-d.ts')], + include: ['test/unit/**/*.test-d.ts'], }, setupFiles: [ resolve(__dirname, '__helpers__/expect.ts'),